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 # Location tracking QA checklist
Applies to the BackgroundGeolocation integration: Applies to the BackgroundGeolocation integration:
- [`trackLocation()`](src/location/trackLocation.js:34) - [`trackLocation()`](src/location/trackLocation.js:11)
- [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:126) - [`createTrackingController()`](src/location/bggeo/createTrackingController.js:1)
- [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:190)
## Goals ## 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. - 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. - We intentionally do not rely on time-based updates.
- ACTIVE uses `geolocation.distanceFilter: 25`. - 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: - Upload strategy is intentionally simple:
- Keep only the latest persisted geopoint: `persistence.maxRecordsToPersist: 1`. - Keep only the latest persisted geopoint: `persistence.maxRecordsToPersist: 1`.
- No batching / thresholds: `batchSync: false`, `autoSyncThreshold: 0`. - No batching / thresholds: `batchSync: false`, `autoSyncThreshold: 0`.
- When authenticated, each persisted location should upload immediately via native HTTP (works while JS is suspended). - 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: - Stationary noise suppression:
- Native accuracy gate for persisted/uploaded locations: `geolocation.filter.trackingAccuracyThreshold: 100`. - 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 | | 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 | 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 | swipe-away | IDLE | ~250m | native geofence triggers; verify server update; app may relaunch to deliver JS logs |
| Android | foreground | ACTIVE | ~30m | location + upload continues | | Android | foreground | ACTIVE | ~30m | location + upload continues |
@ -138,10 +139,9 @@ Applies to the BackgroundGeolocation integration:
## What to look for in logs ## 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`). - No time-based uploads: heartbeat is disabled (`heartbeatInterval: 0`).
- Movement-only uploads: - 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). - ACTIVE distance threshold: `distanceFilter: 25` in [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:148).
- Attribution for `getCurrentPosition`: - Attribution for `getCurrentPosition`:
@ -154,13 +154,10 @@ Applies to the BackgroundGeolocation integration:
## Debugging tips ## Debugging tips
- Observe logs in app: - Observe logs in app (dev/staging):
- `tracking_ctx` extras are updated on AppState changes and profile changes. - `Motion change` edges
- See [`updateTrackingContextExtras()`](src/location/trackLocation.js:63). - `HTTP response` when uploads fail or in dev/staging
- Correlate: - pending queue (`BackgroundGeolocation.getCount()` via [`bggeoGetStatusSnapshot()`](src/location/bggeo/diagnostics.js:15))
- `onLocation` events
- `onHttp` events
- pending queue (`BackgroundGeolocation.getCount()` in logs)
## Android-specific note (stationary-geofence EXIT loop) ## 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). - 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). - 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. - Android IDLE no longer uses `geolocation.useSignificantChangesOnly`.
- See [`TRACKING_PROFILES.idle.geolocation.useSignificantChangesOnly`](src/location/backgroundGeolocationConfig.js:1). - 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: Diagnostics:
- `onGeofence` events are logged (identifier/action/accuracy + current BGGeo state) to confirm whether the SDK is emitting stationary geofence events. - `onGeofence` events are not explicitly logged anymore (we rely on motion/location/http + the in-app diagnostics helpers).
- See [`setBackgroundGeolocationEventHandlers({ onGeofence })`](src/location/trackLocation.js:1).

View file

@ -4,6 +4,7 @@ import env from "~/env";
const LOCATION_ACCURACY_GATE_M = 100; const LOCATION_ACCURACY_GATE_M = 100;
const IS_ANDROID = Platform.OS === "android"; const IS_ANDROID = Platform.OS === "android";
const IS_DEBUG_LOGGING = __DEV__ || env.IS_STAGING;
// Native filter to reduce GPS drift and suppress stationary jitter. // Native filter to reduce GPS drift and suppress stationary jitter.
// This is the primary mechanism to prevent unwanted persisted/uploaded points while the device // 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 config
logger: { logger: {
// debug: true, // Logging can become large and also adds overhead.
// Logging can become large and also adds overhead; keep verbose logs to dev/staging. // Keep verbose logs to dev/staging.
logLevel: debug: IS_DEBUG_LOGGING,
__DEV__ || env.IS_STAGING logLevel: IS_DEBUG_LOGGING
? BackgroundGeolocation.LogLevel.Verbose ? BackgroundGeolocation.LogLevel.Verbose
: BackgroundGeolocation.LogLevel.Error, : BackgroundGeolocation.LogLevel.Error,
}, },
@ -200,7 +201,10 @@ export const TRACKING_PROFILES = {
// Android IDLE: rely on OS-level significant movement only. // Android IDLE: rely on OS-level significant movement only.
// This avoids periodic wakeups/records due to poor fused-location fixes while the phone // This avoids periodic wakeups/records due to poor fused-location fixes while the phone
// is stationary (screen-off / locked scenarios). // 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. // QA helper: allow easier validation in dev/staging while keeping production at 200m.
stationaryRadius: 200, stationaryRadius: 200,
@ -212,8 +216,17 @@ export const TRACKING_PROFILES = {
activity: { activity: {
// 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: 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: { active: {
geolocation: { geolocation: {

View file

@ -15,6 +15,12 @@ let lastReadyState = null;
let subscriptions = []; let subscriptions = [];
let handlersSignature = null; let handlersSignature = null;
export function clearBackgroundGeolocationEventHandlers() {
subscriptions.forEach((s) => s?.remove?.());
subscriptions = [];
handlersSignature = null;
}
export async function ensureBackgroundGeolocationReady( export async function ensureBackgroundGeolocationReady(
config = BASE_GEOLOCATION_CONFIG, config = BASE_GEOLOCATION_CONFIG,
) { ) {
@ -81,8 +87,7 @@ export function setBackgroundGeolocationEventHandlers({
return; return;
} }
subscriptions.forEach((s) => s?.remove?.()); clearBackgroundGeolocationEventHandlers();
subscriptions = [];
if (onLocation) { if (onLocation) {
subscriptions.push( 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 { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import { getAuthState } from "~/stores";
import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService";
import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig";
@ -46,6 +48,16 @@ export const enableEmulatorMode = async () => {
} }
try { 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); await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Call immediately once // Call immediately once

View file

@ -1,5 +1,5 @@
import BackgroundGeolocation from "react-native-background-geolocation";
import { Alert } from "react-native"; import { Alert } from "react-native";
import * as Location from "expo-location";
import { getLocationState } from "~/stores"; import { getLocationState } from "~/stores";
@ -7,11 +7,6 @@ import openSettings from "~/lib/native/openSettings";
import setLocationState from "./setLocationState"; 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 MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second const RETRY_DELAY = 1000; // 1 second
@ -20,15 +15,11 @@ export async function getCurrentLocation() {
while (retries < MAX_RETRIES) { while (retries < MAX_RETRIES) {
try { try {
// Vendor requirement: never call APIs like getState/requestPermission/getCurrentPosition // UI-only location must NOT depend on BGGeo.
// before `.ready()` has resolved. // Policy: pre-auth, BGGeo remains completely unused.
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Check for location permissions and services const servicesEnabled = await Location.hasServicesEnabledAsync();
const state = await BackgroundGeolocation.getState(); if (!servicesEnabled) {
if (!state.enabled) {
// Prompt the user to enable location services manually
Alert.alert( Alert.alert(
"Services de localisation désactivés", "Services de localisation désactivés",
"Veuillez activer les services de localisation pour utiliser cette fonctionnalité.", "Veuillez activer les services de localisation pour utiliser cette fonctionnalité.",
@ -39,17 +30,16 @@ export async function getCurrentLocation() {
); );
return null; return null;
} }
const authorizationStatus =
await BackgroundGeolocation.requestPermission();
const isAuthorized = const perm = await Location.getForegroundPermissionsAsync();
authorizationStatus === let status = perm?.status;
BackgroundGeolocation.AuthorizationStatus?.Always ||
authorizationStatus ===
BackgroundGeolocation.AuthorizationStatus?.WhenInUse;
if (!isAuthorized) { if (status !== "granted") {
// If unable to get permissions, provide a link to settings const req = await Location.requestForegroundPermissionsAsync();
status = req?.status;
}
if (status !== "granted") {
Alert.alert( Alert.alert(
"Autorisation de localisation requise", "Autorisation de localisation requise",
"Veuillez accorder l'autorisation de localisation pour utiliser cette fonctionnalité.", "Veuillez accorder l'autorisation de localisation pour utiliser cette fonctionnalité.",
@ -61,18 +51,27 @@ export async function getCurrentLocation() {
return null; return null;
} }
// UI lookup: do not persist. Persisting can create a DB record and trigger // Add a lightweight timeout wrapper to avoid hanging UI.
// native HTTP upload even if the user has not moved. const TIMEOUT_MS = 30000;
const location = await BackgroundGeolocation.getCurrentPosition({ const timeout = new Promise((_, reject) =>
timeout: 30, setTimeout(() => reject(new Error("Location timeout")), TIMEOUT_MS),
persist: false, );
maximumAge: 5000,
desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High, const loc = await Promise.race([
samples: 1, Location.getCurrentPositionAsync({
}); accuracy: Location.Accuracy.High,
const coords = camelCaseKeys(location.coords); mayShowUserSettingsDialog: false,
}),
timeout,
]);
const coords = loc?.coords;
if (coords) {
setLocationState(coords); setLocationState(coords);
return coords; return coords;
}
return null;
} catch (error) { } catch (error) {
console.log( console.log(
`Erreur lors de l'obtention de la position actuelle (tentative ${ `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 React, { useState, useEffect } from "react";
import { View, StyleSheet, ScrollView } from "react-native"; import { View, StyleSheet, ScrollView } from "react-native";
import * as Sentry from "@sentry/react-native"; import * as Sentry from "@sentry/react-native";
import BackgroundGeolocation from "react-native-background-geolocation";
import { import {
Button, Button,
Card, Card,
@ -22,8 +21,11 @@ import {
import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger"; import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger";
import { config as loggerConfig } from "~/lib/logger/config"; import { config as loggerConfig } from "~/lib/logger/config";
import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService"; import {
import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig"; bggeoGetDiagnosticsSnapshot,
bggeoGetStatusSnapshot,
bggeoSyncNow,
} from "~/location/bggeo/diagnostics";
const reset = async () => { const reset = async () => {
await authActions.logout(); await authActions.logout();
@ -48,6 +50,8 @@ export default function Developer() {
const [syncResult, setSyncResult] = useState(""); const [syncResult, setSyncResult] = useState("");
const [bgGeoStatus, setBgGeoStatus] = useState(null); // null, 'loading', 'success', 'error' const [bgGeoStatus, setBgGeoStatus] = useState(null); // null, 'loading', 'success', 'error'
const [bgGeoResult, setBgGeoResult] = useState(""); const [bgGeoResult, setBgGeoResult] = useState("");
const [bgGeoDiagStatus, setBgGeoDiagStatus] = useState(null);
const [bgGeoDiagResult, setBgGeoDiagResult] = 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,25 +84,18 @@ export default function Developer() {
setSyncStatus("syncing"); setSyncStatus("syncing");
setSyncResult(""); setSyncResult("");
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); const [{ enabled, isMoving, trackingMode }, sync] = await Promise.all([
bggeoGetStatusSnapshot(),
bggeoSyncNow(),
]);
const state = await BackgroundGeolocation.getState(); const result = `Synced ${String(sync?.synced)} records (${String(
sync?.pendingBefore,
// Get the count of pending records first )} pending before, ${String(
const count = await BackgroundGeolocation.getCount(); sync?.pendingAfter,
)} pending after). enabled=${String(enabled)} isMoving=${String(
// Perform the sync isMoving,
const records = await BackgroundGeolocation.sync(); )} trackingMode=${String(trackingMode)}`;
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,
)}`;
setSyncResult(result); setSyncResult(result);
setSyncStatus("success"); setSyncStatus("success");
} catch (error) { } catch (error) {
@ -113,17 +110,12 @@ export default function Developer() {
setBgGeoStatus("loading"); setBgGeoStatus("loading");
setBgGeoResult(""); setBgGeoResult("");
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); const snap = await bggeoGetStatusSnapshot();
const [state, count] = await Promise.all([ const result = `enabled=${String(snap?.enabled)} isMoving=${String(
BackgroundGeolocation.getState(), snap?.isMoving,
BackgroundGeolocation.getCount(), )} trackingMode=${String(snap?.trackingMode)} schedulerEnabled=${String(
]); snap?.schedulerEnabled,
)} pending=${String(snap?.pending)}`;
const result = `enabled=${String(state?.enabled)} isMoving=${String(
state?.isMoving,
)} trackingMode=${String(state?.trackingMode)} schedulerEnabled=${String(
state?.schedulerEnabled,
)} pending=${String(count)}`;
setBgGeoResult(result); setBgGeoResult(result);
setBgGeoStatus("success"); setBgGeoStatus("success");
} catch (error) { } catch (error) {
@ -132,6 +124,40 @@ export default function Developer() {
setBgGeoStatus("error"); 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 = () => { const triggerNullError = () => {
try { try {
// Wrap the null error in try-catch // Wrap the null error in try-catch
@ -212,6 +238,23 @@ export default function Developer() {
</View> </View>
</Section> </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"> <Section title="Logging Controls">
<Text variant="bodyLarge" style={styles.sectionLabel}> <Text variant="bodyLarge" style={styles.sectionLabel}>
Log Level Log Level