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 BackgroundGeolocation from "react-native-background-geolocation";
import notifee from "@notifee/react-native";
import messaging from "@react-native-firebase/messaging";
@ -19,6 +21,17 @@ import onMessageReceived from "~/notifications/onMessageReceived";
notifee.onBackgroundEvent(notificationBackgroundEvent);
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);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately

View file

@ -1,10 +1,13 @@
import BackgroundGeolocation from "react-native-background-geolocation";
import { TRACK_MOVE } from "~/misc/devicePrefs";
import env from "~/env";
// 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.
//
// 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:
// - 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.
@ -18,10 +21,21 @@ export const BASE_GEOLOCATION_CONFIG = {
// Default to the IDLE profile behaviour: we still want distance-based updates
// 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,
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
locationAuthorizationRequest: "Always",
@ -31,7 +45,9 @@ export const BASE_GEOLOCATION_CONFIG = {
startOnBoot: true,
// 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
foregroundService: true,
@ -71,15 +87,52 @@ export const BASE_GEOLOCATION_CONFIG = {
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 = {
idle: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
distanceFilter: 50,
heartbeatInterval: 3600,
// Max battery-saving strategy for IDLE:
// 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: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
distanceFilter: TRACK_MOVE,
heartbeatInterval: 900,
// Ensure we exit significant-changes mode when switching from IDLE.
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,
onLocationError,
onHttp,
onHeartbeat,
onSchedule,
onMotionChange,
onActivityChange,
onProviderChange,
@ -65,6 +67,8 @@ export function setBackgroundGeolocationEventHandlers({
const sig = [
onLocation ? "L1" : "L0",
onHttp ? "H1" : "H0",
onHeartbeat ? "HB1" : "HB0",
onSchedule ? "S1" : "S0",
onMotionChange ? "M1" : "M0",
onActivityChange ? "A1" : "A0",
onProviderChange ? "P1" : "P0",
@ -86,6 +90,22 @@ export function setBackgroundGeolocationEventHandlers({
if (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) {
subscriptions.push(BackgroundGeolocation.onMotionChange(onMotionChange));
}

View file

@ -21,6 +21,7 @@ import env from "~/env";
import {
BASE_GEOLOCATION_CONFIG,
BASE_GEOLOCATION_INVARIANTS,
TRACKING_PROFILES,
} from "~/location/backgroundGeolocationConfig";
import {
@ -129,6 +130,8 @@ export default function trackLocation() {
}
if (currentProfile === profileName) return;
const applyStartedAt = Date.now();
const profile = TRACKING_PROFILES[profileName];
if (!profile) {
locationLogger.warn("Unknown tracking profile", { profileName });
@ -147,14 +150,71 @@ export default function trackLocation() {
// Motion state strategy:
// - ACTIVE: force moving to begin aggressive tracking immediately.
// - IDLE: do NOT force stationary. Let the SDK's motion detection manage
// moving/stationary transitions so we still get distance-based updates
// (target: new point when moved ~50m+ even without an open alert).
// - IDLE: ensure we are not stuck in moving mode from a prior ACTIVE session.
// We explicitly exit moving mode to avoid periodic drift-generated locations
// 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") {
const state = await BackgroundGeolocation.getState();
if (!state?.isMoving) {
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;
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) {
locationLogger.error("Failed to apply tracking profile", {
profileName,
@ -338,6 +398,40 @@ export default function trackLocation() {
status: response?.status,
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) => {
locationLogger.info("Motion change", {
@ -374,6 +468,17 @@ export default function trackLocation() {
locationLogger.info("Initializing background geolocation");
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
const state = await BackgroundGeolocation.getState();
locationLogger.debug("Background geolocation state", {

View file

@ -1,5 +1,4 @@
// 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_REACH = 25000;
export const MAX_BASEUSER_DEVICE_TRACKING = 25000;