fix(android): track location battery saving

This commit is contained in:
devthejo 2026-01-20 23:25:09 +01:00
parent 782448af3d
commit a2acbb6d0b
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
5 changed files with 203 additions and 13 deletions

View file

@ -3,6 +3,8 @@ import "./warnFilter";
import "expo-splash-screen"; import "expo-splash-screen";
import BackgroundGeolocation from "react-native-background-geolocation";
import notifee from "@notifee/react-native"; import notifee from "@notifee/react-native";
import messaging from "@react-native-firebase/messaging"; import messaging from "@react-native-firebase/messaging";
@ -19,6 +21,17 @@ import onMessageReceived from "~/notifications/onMessageReceived";
notifee.onBackgroundEvent(notificationBackgroundEvent); notifee.onBackgroundEvent(notificationBackgroundEvent);
messaging().setBackgroundMessageHandler(onMessageReceived); messaging().setBackgroundMessageHandler(onMessageReceived);
// Android Headless Mode for react-native-background-geolocation.
// Required because [`enableHeadless`](src/location/backgroundGeolocationConfig.js:16) is enabled and
// we run with [`stopOnTerminate: false`](src/location/backgroundGeolocationConfig.js:40).
//
// IMPORTANT: keep this handler lightweight. In headless state, the JS runtime may be launched
// briefly and then torn down; long tasks can be terminated by the OS.
BackgroundGeolocation.registerHeadlessTask(async (event) => {
// eslint-disable-next-line no-console
console.log("[BGGeo HeadlessTask]", event?.name, event?.params);
});
// registerRootComponent calls AppRegistry.registerComponent('main', () => App); // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build, // It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately // the environment is set up appropriately

View file

@ -1,10 +1,13 @@
import BackgroundGeolocation from "react-native-background-geolocation"; import BackgroundGeolocation from "react-native-background-geolocation";
import { TRACK_MOVE } from "~/misc/devicePrefs";
import env from "~/env"; import env from "~/env";
// Common config: keep always-on tracking enabled, but default to an IDLE low-power profile. // Common config: keep always-on tracking enabled, but default to an IDLE low-power profile.
// High-accuracy and moving mode are enabled only when an active alert is open. // High-accuracy and moving mode are enabled only when an active alert is open.
// //
// Product goals:
// - IDLE (no open alert): minimize battery; server updates are acceptable only on OS-level significant movement.
// - ACTIVE (open alert): first location should reach server within seconds, then continuous distance-based updates.
//
// Notes: // Notes:
// - We avoid `reset: true` in production because it can unintentionally wipe persisted / configured state. // - We avoid `reset: true` in production because it can unintentionally wipe persisted / configured state.
// In dev, `reset: true` is useful to avoid config drift while iterating. // In dev, `reset: true` is useful to avoid config drift while iterating.
@ -18,10 +21,21 @@ export const BASE_GEOLOCATION_CONFIG = {
// Default to the IDLE profile behaviour: we still want distance-based updates // Default to the IDLE profile behaviour: we still want distance-based updates
// even with no open alert (see TRACKING_PROFILES.idle). // even with no open alert (see TRACKING_PROFILES.idle).
distanceFilter: 50, distanceFilter: 200,
// Activity-recognition stop-detection.
// NOTE: Transistorsoft defaults `stopTimeout` to 5 minutes (see
// [`node_modules/react-native-background-geolocation/src/declarations/interfaces/Config.d.ts:79`](node_modules/react-native-background-geolocation/src/declarations/interfaces/Config.d.ts:79)).
// We keep the default in BASE and override it in the IDLE profile to reduce
// 5-minute stationary cycles observed on Android.
stopTimeout: 5,
// debug: true, // debug: true,
logLevel: BackgroundGeolocation.LOG_LEVEL_VERBOSE, // Logging can become large and also adds overhead; keep verbose logs to dev/staging.
logLevel:
__DEV__ || env.IS_STAGING
? BackgroundGeolocation.LOG_LEVEL_VERBOSE
: BackgroundGeolocation.LOG_LEVEL_ERROR,
// Permission request strategy // Permission request strategy
locationAuthorizationRequest: "Always", locationAuthorizationRequest: "Always",
@ -31,7 +45,9 @@ export const BASE_GEOLOCATION_CONFIG = {
startOnBoot: true, startOnBoot: true,
// Background scheduling // Background scheduling
heartbeatInterval: 3600, // Disable heartbeats by default to avoid periodic background wakeups while stationary.
// ACTIVE profile will explicitly enable a fast heartbeat when needed.
heartbeatInterval: 0,
// Android foreground service // Android foreground service
foregroundService: true, foregroundService: true,
@ -71,15 +87,52 @@ export const BASE_GEOLOCATION_CONFIG = {
disableProviderChangeRecord: true, disableProviderChangeRecord: true,
}; };
// Options we want to be stable across launches even when the plugin loads a persisted config.
// NOTE: We intentionally do *not* include HTTP auth headers here.
export const BASE_GEOLOCATION_INVARIANTS = {
enableHeadless: true,
stopOnTerminate: false,
startOnBoot: true,
foregroundService: true,
disableProviderChangeRecord: true,
// Filter extreme GPS teleports that can create false uploads while stationary.
// Units: meters/second. 100 m/s ~= 360 km/h.
speedJumpFilter: 100,
method: "POST",
httpRootProperty: "location",
maxRecordsToPersist: 1000,
maxDaysToPersist: 7,
};
export const TRACKING_PROFILES = { export const TRACKING_PROFILES = {
idle: { idle: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW, desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
distanceFilter: 50, // Max battery-saving strategy for IDLE:
heartbeatInterval: 3600, // Use Android/iOS low-power significant-change tracking where the OS produces
// only periodic fixes (several times/hour). Note many config options like
// `distanceFilter` / `stationaryRadius` are documented as having little/no
// effect in this mode.
useSignificantChangesOnly: true,
// Defensive: if some devices/platform conditions fall back to standard tracking,
// keep the distanceFilter conservative to avoid battery drain.
distanceFilter: 200,
// Keep the default stop-detection timing (minutes). In significant-changes
// mode, stop-detection is not the primary driver of updates.
stopTimeout: 5,
// No periodic wakeups while idle.
heartbeatInterval: 0,
}, },
active: { active: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH, desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
distanceFilter: TRACK_MOVE, // Ensure we exit significant-changes mode when switching from IDLE.
heartbeatInterval: 900, useSignificantChangesOnly: false,
distanceFilter: 50,
heartbeatInterval: 60,
// Keep default responsiveness during an active alert.
stopTimeout: 5,
}, },
}; };

View file

@ -54,6 +54,8 @@ export function setBackgroundGeolocationEventHandlers({
onLocation, onLocation,
onLocationError, onLocationError,
onHttp, onHttp,
onHeartbeat,
onSchedule,
onMotionChange, onMotionChange,
onActivityChange, onActivityChange,
onProviderChange, onProviderChange,
@ -65,6 +67,8 @@ export function setBackgroundGeolocationEventHandlers({
const sig = [ const sig = [
onLocation ? "L1" : "L0", onLocation ? "L1" : "L0",
onHttp ? "H1" : "H0", onHttp ? "H1" : "H0",
onHeartbeat ? "HB1" : "HB0",
onSchedule ? "S1" : "S0",
onMotionChange ? "M1" : "M0", onMotionChange ? "M1" : "M0",
onActivityChange ? "A1" : "A0", onActivityChange ? "A1" : "A0",
onProviderChange ? "P1" : "P0", onProviderChange ? "P1" : "P0",
@ -86,6 +90,22 @@ export function setBackgroundGeolocationEventHandlers({
if (onHttp) { if (onHttp) {
subscriptions.push(BackgroundGeolocation.onHttp(onHttp)); subscriptions.push(BackgroundGeolocation.onHttp(onHttp));
} }
if (onHeartbeat) {
if (typeof BackgroundGeolocation.onHeartbeat === "function") {
subscriptions.push(BackgroundGeolocation.onHeartbeat(onHeartbeat));
} else {
bgGeoLogger.warn("BackgroundGeolocation.onHeartbeat is not available");
}
}
if (onSchedule) {
if (typeof BackgroundGeolocation.onSchedule === "function") {
subscriptions.push(BackgroundGeolocation.onSchedule(onSchedule));
} else {
bgGeoLogger.warn("BackgroundGeolocation.onSchedule is not available");
}
}
if (onMotionChange) { if (onMotionChange) {
subscriptions.push(BackgroundGeolocation.onMotionChange(onMotionChange)); subscriptions.push(BackgroundGeolocation.onMotionChange(onMotionChange));
} }

View file

@ -21,6 +21,7 @@ import env from "~/env";
import { import {
BASE_GEOLOCATION_CONFIG, BASE_GEOLOCATION_CONFIG,
BASE_GEOLOCATION_INVARIANTS,
TRACKING_PROFILES, TRACKING_PROFILES,
} from "~/location/backgroundGeolocationConfig"; } from "~/location/backgroundGeolocationConfig";
import { import {
@ -129,6 +130,8 @@ export default function trackLocation() {
} }
if (currentProfile === profileName) return; if (currentProfile === profileName) return;
const applyStartedAt = Date.now();
const profile = TRACKING_PROFILES[profileName]; const profile = TRACKING_PROFILES[profileName];
if (!profile) { if (!profile) {
locationLogger.warn("Unknown tracking profile", { profileName }); locationLogger.warn("Unknown tracking profile", { profileName });
@ -147,14 +150,71 @@ export default function trackLocation() {
// Motion state strategy: // Motion state strategy:
// - ACTIVE: force moving to begin aggressive tracking immediately. // - ACTIVE: force moving to begin aggressive tracking immediately.
// - IDLE: do NOT force stationary. Let the SDK's motion detection manage // - IDLE: ensure we are not stuck in moving mode from a prior ACTIVE session.
// moving/stationary transitions so we still get distance-based updates // We explicitly exit moving mode to avoid periodic drift-generated locations
// (target: new point when moved ~50m+ even without an open alert). // being produced + uploaded while the user is stationary (reported on Android).
// After that, let the SDK's motion detection manage moving/stationary
// transitions so we still get distance-based updates when the user truly moves.
if (profileName === "active") { if (profileName === "active") {
const state = await BackgroundGeolocation.getState();
if (!state?.isMoving) {
await BackgroundGeolocation.changePace(true); await BackgroundGeolocation.changePace(true);
} }
// Guarantee a rapid first fix for ACTIVE: request a high-accuracy persisted location
// immediately after entering moving mode. This is preferred over relying solely on
// motion-detection / distanceFilter to produce the first point.
try {
const beforeFix = Date.now();
const fix = await BackgroundGeolocation.getCurrentPosition({
samples: 3,
persist: true,
timeout: 30,
maximumAge: 0,
desiredAccuracy: 10,
extras: {
active_profile_enter: true,
},
});
locationLogger.info("ACTIVE immediate fix acquired", {
ms: Date.now() - beforeFix,
accuracy: fix?.coords?.accuracy,
latitude: fix?.coords?.latitude,
longitude: fix?.coords?.longitude,
timestamp: fix?.timestamp,
});
} catch (error) {
locationLogger.warn("ACTIVE immediate fix failed", {
error: error?.message,
code: error?.code,
stack: error?.stack,
});
}
} else {
const state = await BackgroundGeolocation.getState();
if (state?.isMoving) {
await BackgroundGeolocation.changePace(false);
}
}
currentProfile = profileName; currentProfile = profileName;
try {
const state = await BackgroundGeolocation.getState();
locationLogger.info("Tracking profile applied", {
profileName,
ms: Date.now() - applyStartedAt,
enabled: state?.enabled,
isMoving: state?.isMoving,
trackingMode: state?.trackingMode,
});
} catch (e) {
locationLogger.debug("Tracking profile applied (state unavailable)", {
profileName,
ms: Date.now() - applyStartedAt,
error: e?.message,
});
}
} catch (error) { } catch (error) {
locationLogger.error("Failed to apply tracking profile", { locationLogger.error("Failed to apply tracking profile", {
profileName, profileName,
@ -338,6 +398,40 @@ export default function trackLocation() {
status: response?.status, status: response?.status,
responseText: response?.responseText, responseText: response?.responseText,
}); });
// Instrumentation: when we see periodic HTTP without a corresponding location event,
// we want to know if BGGeo is retrying an upload queue or flushing new records.
// This helps diagnose reports like "server receives updates every ~5 minutes while stationary".
try {
const [state, count] = await Promise.all([
BackgroundGeolocation.getState(),
BackgroundGeolocation.getCount(),
]);
locationLogger.debug("HTTP instrumentation", {
enabled: state?.enabled,
isMoving: state?.isMoving,
trackingMode: state?.trackingMode,
schedulerEnabled: state?.schedulerEnabled,
pendingCount: count,
});
} catch (e) {
locationLogger.warn("Failed HTTP instrumentation", {
error: e?.message,
});
}
},
onHeartbeat: (event) => {
// If heartbeat is configured, it can trigger sync attempts even without new locations.
locationLogger.info("Heartbeat", {
enabled: event?.state?.enabled,
isMoving: event?.state?.isMoving,
location: event?.location?.coords,
});
},
onSchedule: (event) => {
locationLogger.info("Schedule", {
state: event?.state,
});
}, },
onMotionChange: (event) => { onMotionChange: (event) => {
locationLogger.info("Motion change", { locationLogger.info("Motion change", {
@ -374,6 +468,17 @@ export default function trackLocation() {
locationLogger.info("Initializing background geolocation"); locationLogger.info("Initializing background geolocation");
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG); await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Ensure critical config cannot drift due to persisted plugin state.
// (We intentionally keep auth headers separate and set them in handleAuth.)
try {
await BackgroundGeolocation.setConfig(BASE_GEOLOCATION_INVARIANTS);
} catch (e) {
locationLogger.warn("Failed to apply BGGeo base invariants", {
error: e?.message,
stack: e?.stack,
});
}
// Only set the permission state if we already have the permission // Only set the permission state if we already have the permission
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();
locationLogger.debug("Background geolocation state", { locationLogger.debug("Background geolocation state", {

View file

@ -1,5 +1,4 @@
// related to services/tasks/src/geocode/config.js // related to services/tasks/src/geocode/config.js
export const TRACK_MOVE = 10;
export const DEFAULT_DEVICE_RADIUS_ALL = 500; export const DEFAULT_DEVICE_RADIUS_ALL = 500;
export const DEFAULT_DEVICE_RADIUS_REACH = 25000; export const DEFAULT_DEVICE_RADIUS_REACH = 25000;
export const MAX_BASEUSER_DEVICE_TRACKING = 25000; export const MAX_BASEUSER_DEVICE_TRACKING = 25000;