Compare commits

..

No commits in common. "21e121ea05be4e12d17184229c7523a38452e65e" and "b8c57520dfc6bc93973f049ec680ad0c9cb9aebe" have entirely different histories.

13 changed files with 1459 additions and 855 deletions

View file

@ -2,13 +2,6 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [1.16.9](https://github.com/alerte-secours/as-app/compare/v1.16.8...v1.16.9) (2026-02-08)
### Bug Fixes
* **track-location:** try 11 ([88fbd72](https://github.com/alerte-secours/as-app/commit/88fbd72e5144c36c01d9e047ea43faf69014c9f6))
## [1.16.8](https://github.com/alerte-secours/as-app/compare/v1.16.7...v1.16.8) (2026-02-05) ## [1.16.8](https://github.com/alerte-secours/as-app/compare/v1.16.7...v1.16.8) (2026-02-05)

View file

@ -83,8 +83,8 @@ android {
applicationId 'com.alertesecours' applicationId 'com.alertesecours'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 240 versionCode 239
versionName "1.16.9" versionName "1.16.8"
multiDexEnabled true multiDexEnabled true
testBuildType System.getProperty('testBuildType', 'debug') testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View file

@ -1,9 +1,8 @@
# Location tracking QA checklist # Location tracking QA checklist
Applies to the BackgroundGeolocation integration: Applies to the BackgroundGeolocation integration:
- [`trackLocation()`](src/location/trackLocation.js:11) - [`trackLocation()`](src/location/trackLocation.js:34)
- [`createTrackingController()`](src/location/bggeo/createTrackingController.js:1) - [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:126)
- [`TRACKING_PROFILES`](src/location/backgroundGeolocationConfig.js:190)
## Goals ## Goals
@ -21,12 +20,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/bggeo/createTrackingController.js:170)). - JS may request a persisted fix when entering ACTIVE (see [`applyProfile()`](src/location/trackLocation.js:351)).
- 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: BGGeo tracking is disabled (do not start). UI-only location uses `expo-location`. - Pre-auth: tracking may persist locally but `http.url` is empty so nothing is uploaded until auth is ready.
- 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`.
@ -130,7 +129,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/bggeo/createTrackingController.js:311) then [`onLocation`](src/location/bggeo/createTrackingController.js:286) (sample=false), then [`onHttp`](src/location/bggeo/createTrackingController.js:302) | | 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 | 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 |
@ -139,9 +138,10 @@ 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). - IDLE: look for `Motion change` (isMoving=true) and (in rare cases) `IDLE movement fallback fix`.
- 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,10 +154,13 @@ Applies to the BackgroundGeolocation integration:
## Debugging tips ## Debugging tips
- Observe logs in app (dev/staging): - Observe logs in app:
- `Motion change` edges - `tracking_ctx` extras are updated on AppState changes and profile changes.
- `HTTP response` when uploads fail or in dev/staging - See [`updateTrackingContextExtras()`](src/location/trackLocation.js:63).
- pending queue (`BackgroundGeolocation.getCount()` via [`bggeoGetStatusSnapshot()`](src/location/bggeo/diagnostics.js:15)) - Correlate:
- `onLocation` events
- `onHttp` events
- pending queue (`BackgroundGeolocation.getCount()` in logs)
## Android-specific note (stationary-geofence EXIT loop) ## Android-specific note (stationary-geofence EXIT loop)
@ -169,12 +172,10 @@ 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 no longer uses `geolocation.useSignificantChangesOnly`. - Android IDLE uses `geolocation.useSignificantChangesOnly: true` to rely on OS-level significant movement events.
- Reason: this mode can record only "several times/hour" and was observed to miss timely updates - See [`TRACKING_PROFILES.idle.geolocation.useSignificantChangesOnly`](src/location/backgroundGeolocationConfig.js:1).
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 not explicitly logged anymore (we rely on motion/location/http + the in-app diagnostics helpers). - `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).

View file

@ -25,7 +25,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.16.9</string> <string>1.16.8</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -48,7 +48,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>240</string> <string>239</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>

View file

@ -1,6 +1,6 @@
{ {
"name": "alerte-secours", "name": "alerte-secours",
"version": "1.16.9", "version": "1.16.8",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "expo start --dev-client --private-key-path ./keys/private-key.pem", "start": "expo start --dev-client --private-key-path ./keys/private-key.pem",
@ -54,8 +54,8 @@
"screenshot:android": "scripts/screenshot-android.sh" "screenshot:android": "scripts/screenshot-android.sh"
}, },
"customExpoVersioning": { "customExpoVersioning": {
"versionCode": 240, "versionCode": 239,
"buildNumber": 240 "buildNumber": 239
}, },
"commit-and-tag-version": { "commit-and-tag-version": {
"scripts": { "scripts": {

View file

@ -4,7 +4,6 @@ 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
@ -42,12 +41,12 @@ export const BASE_GEOLOCATION_CONFIG = {
// Logger config // Logger config
logger: { logger: {
// Logging can become large and also adds overhead. // debug: true,
// Keep verbose logs to dev/staging. // Logging can become large and also adds overhead; keep verbose logs to dev/staging.
debug: IS_DEBUG_LOGGING, logLevel:
logLevel: IS_DEBUG_LOGGING __DEV__ || env.IS_STAGING
? BackgroundGeolocation.LogLevel.Verbose ? BackgroundGeolocation.LogLevel.Verbose
: BackgroundGeolocation.LogLevel.Error, : BackgroundGeolocation.LogLevel.Error,
}, },
// Geolocation config // Geolocation config
@ -201,10 +200,7 @@ 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).
// However, this mode can also delay updates for many minutes ("several times / hour"), useSignificantChangesOnly: IS_ANDROID,
// 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,
@ -216,17 +212,8 @@ 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.)
// 5 minutes was observed to be too aggressive and can prevent a moving transition during motionTriggerDelay: 300000,
// 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,12 +15,6 @@ 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,
) { ) {
@ -87,7 +81,8 @@ export function setBackgroundGeolocationEventHandlers({
return; return;
} }
clearBackgroundGeolocationEventHandlers(); subscriptions.forEach((s) => s?.remove?.());
subscriptions = [];
if (onLocation) { if (onLocation) {
subscriptions.push( subscriptions.push(

View file

@ -1,560 +0,0 @@
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

@ -1,75 +0,0 @@
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,8 +4,6 @@ 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";
@ -48,16 +46,6 @@ 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,6 +7,11 @@ 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
@ -15,11 +20,15 @@ export async function getCurrentLocation() {
while (retries < MAX_RETRIES) { while (retries < MAX_RETRIES) {
try { try {
// UI-only location must NOT depend on BGGeo. // Vendor requirement: never call APIs like getState/requestPermission/getCurrentPosition
// Policy: pre-auth, BGGeo remains completely unused. // before `.ready()` has resolved.
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
const servicesEnabled = await Location.hasServicesEnabledAsync(); // Check for location permissions and services
if (!servicesEnabled) { const state = await BackgroundGeolocation.getState();
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é.",
@ -30,16 +39,17 @@ export async function getCurrentLocation() {
); );
return null; return null;
} }
const authorizationStatus =
await BackgroundGeolocation.requestPermission();
const perm = await Location.getForegroundPermissionsAsync(); const isAuthorized =
let status = perm?.status; authorizationStatus ===
BackgroundGeolocation.AuthorizationStatus?.Always ||
authorizationStatus ===
BackgroundGeolocation.AuthorizationStatus?.WhenInUse;
if (status !== "granted") { if (!isAuthorized) {
const req = await Location.requestForegroundPermissionsAsync(); // If unable to get permissions, provide a link to settings
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é.",
@ -51,27 +61,18 @@ export async function getCurrentLocation() {
return null; return null;
} }
// Add a lightweight timeout wrapper to avoid hanging UI. // UI lookup: do not persist. Persisting can create a DB record and trigger
const TIMEOUT_MS = 30000; // native HTTP upload even if the user has not moved.
const timeout = new Promise((_, reject) => const location = await BackgroundGeolocation.getCurrentPosition({
setTimeout(() => reject(new Error("Location timeout")), TIMEOUT_MS), timeout: 30,
); persist: false,
maximumAge: 5000,
const loc = await Promise.race([ desiredAccuracy: BackgroundGeolocation.DesiredAccuracy.High,
Location.getCurrentPositionAsync({ samples: 1,
accuracy: Location.Accuracy.High, });
mayShowUserSettingsDialog: false, const coords = camelCaseKeys(location.coords);
}), setLocationState(coords);
timeout, return coords;
]);
const coords = loc?.coords;
if (coords) {
setLocationState(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,6 +1,7 @@
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,
@ -21,11 +22,8 @@ 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 { import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService";
bggeoGetDiagnosticsSnapshot, import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig";
bggeoGetStatusSnapshot,
bggeoSyncNow,
} from "~/location/bggeo/diagnostics";
const reset = async () => { const reset = async () => {
await authActions.logout(); await authActions.logout();
@ -50,8 +48,6 @@ 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
@ -84,18 +80,25 @@ export default function Developer() {
setSyncStatus("syncing"); setSyncStatus("syncing");
setSyncResult(""); setSyncResult("");
const [{ enabled, isMoving, trackingMode }, sync] = await Promise.all([ await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
bggeoGetStatusSnapshot(),
bggeoSyncNow(),
]);
const result = `Synced ${String(sync?.synced)} records (${String( const state = await BackgroundGeolocation.getState();
sync?.pendingBefore,
)} pending before, ${String( // Get the count of pending records first
sync?.pendingAfter, const count = await BackgroundGeolocation.getCount();
)} pending after). enabled=${String(enabled)} isMoving=${String(
isMoving, // Perform the sync
)} trackingMode=${String(trackingMode)}`; 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,
)}`;
setSyncResult(result); setSyncResult(result);
setSyncStatus("success"); setSyncStatus("success");
} catch (error) { } catch (error) {
@ -110,12 +113,17 @@ export default function Developer() {
setBgGeoStatus("loading"); setBgGeoStatus("loading");
setBgGeoResult(""); setBgGeoResult("");
const snap = await bggeoGetStatusSnapshot(); await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
const result = `enabled=${String(snap?.enabled)} isMoving=${String( const [state, count] = await Promise.all([
snap?.isMoving, BackgroundGeolocation.getState(),
)} trackingMode=${String(snap?.trackingMode)} schedulerEnabled=${String( BackgroundGeolocation.getCount(),
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) {
@ -124,40 +132,6 @@ 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
@ -238,23 +212,6 @@ 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