@tsachit/react-native-geo-service 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,22 @@
1
- # react-native-geo-service
1
+ # @tsachit/react-native-geo-service
2
2
 
3
- Battery-efficient background geolocation for React Native.
3
+ Battery-efficient background geolocation for React Native — a lightweight, free alternative to commercial packages.
4
4
 
5
5
  - Tracks location as the user moves and fires a JS listener
6
- - Keeps tracking in the background and when the app is killed (headless mode)
7
- - Uses `FusedLocationProviderClient` on Android and `CLLocationManager` on iOS for maximum battery savings
8
- - **Adaptive accuracy**: GPS turns off automatically when the device is idle and wakes up the moment movement is detected
9
- - Fully configurable from JavaScript
6
+ - Keeps tracking when the app is backgrounded or killed (headless mode)
7
+ - Uses `FusedLocationProviderClient` on Android and `CLLocationManager` on iOS
8
+ - **Adaptive accuracy** GPS turns off automatically when the device is idle and wakes the moment movement is detected
9
+ - **Debug panel** draggable floating overlay that mounts automatically when `debug: true`, showing live metrics, GPS activity, and battery saving suggestions with no component needed in the app tree
10
+ - Fully configurable from JavaScript — no API keys, no license required
10
11
 
11
12
  ---
12
13
 
13
14
  ## Installation
14
15
 
15
16
  ```bash
16
- npm install react-native-geo-service
17
+ yarn add @tsachit/react-native-geo-service
17
18
  # or
18
- yarn add react-native-geo-service
19
+ npm install @tsachit/react-native-geo-service
19
20
  ```
20
21
 
21
22
  ### iOS
@@ -24,7 +25,7 @@ yarn add react-native-geo-service
24
25
  cd ios && pod install
25
26
  ```
26
27
 
27
- Add the following to your `Info.plist`:
28
+ Add to your `Info.plist`:
28
29
 
29
30
  ```xml
30
31
  <!-- Required — explain why you need location -->
@@ -41,23 +42,18 @@ Add the following to your `Info.plist`:
41
42
  </array>
42
43
  ```
43
44
 
44
- #### iOS Headless Mode (app terminated)
45
+ #### iOS AppDelegate (headless relaunch)
45
46
 
46
- When the app is terminated, iOS can still wake it up for location events if you use
47
- `coarseTracking: true` (fires when the device moves ~500 m) or standard background
48
- location updates (requires the Always permission).
49
-
50
- Add this to your **AppDelegate** so tracking resumes after a background relaunch:
47
+ When iOS relaunches a terminated app for a location event, add this so tracking resumes automatically:
51
48
 
52
49
  ```objc
53
50
  // AppDelegate.m
54
51
  - (BOOL)application:(UIApplication *)application
55
52
  didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
56
53
 
57
- // If the app was relaunched in the background due to a location event,
58
- // the location manager delegate will resume tracking automatically.
59
54
  if (launchOptions[UIApplicationLaunchOptionsLocationKey]) {
60
- // Optionally restore your RNGeoService.configure() call here.
55
+ // RNGeoService detects this automatically and resumes tracking
56
+ // from the config it persisted to NSUserDefaults before termination.
61
57
  }
62
58
 
63
59
  // ... rest of your setup
@@ -70,7 +66,7 @@ Add this to your **AppDelegate** so tracking resumes after a background relaunch
70
66
 
71
67
  #### 1. Register the package
72
68
 
73
- **`android/app/src/main/java/.../MainApplication.kt`** (or `.java`):
69
+ **`android/app/src/main/java/.../MainApplication.kt`**:
74
70
 
75
71
  ```kotlin
76
72
  import com.geoservice.GeoServicePackage
@@ -83,7 +79,7 @@ override fun getPackages(): List<ReactPackage> =
83
79
 
84
80
  #### 2. Register the HeadlessJS task
85
81
 
86
- In your app's **`index.js`** (at the top level, outside any component):
82
+ In your app's **`index.js`** (top level, outside any component):
87
83
 
88
84
  ```js
89
85
  import { AppRegistry } from 'react-native';
@@ -91,99 +87,115 @@ import App from './App';
91
87
 
92
88
  AppRegistry.registerComponent('YourApp', () => App);
93
89
 
94
- // Register the background task for when the app is not in the foreground.
95
- // This runs even when the app is killed (as long as the foreground service is active).
90
+ // Handles location events when the React context is not active.
91
+ // Runs even when the app is killed (foreground service must be running).
96
92
  AppRegistry.registerHeadlessTask('GeoServiceHeadlessTask', () => async (location) => {
97
93
  console.log('[Background] Location:', location);
98
- // Send to your server using a pre-stored auth token (e.g. from SecureStore/Keychain).
99
- // Avoid relying on in-memory app state — the JS context here is headless and isolated.
94
+ // Send to your server using a pre-stored auth token (e.g. SecureStore/Keychain).
95
+ // Do not rely on in-memory state — this JS context is isolated.
100
96
  });
101
97
  ```
102
98
 
103
99
  #### 3. Add permissions to `AndroidManifest.xml`
104
100
 
105
- The library's manifest already declares the permissions, but your **app's** manifest must
106
- also include them (merging happens at build time):
107
-
108
101
  ```xml
109
102
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
110
103
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
111
- <!-- Android 10+ — required for background access: -->
104
+ <!-- Android 10+ — required for background access -->
112
105
  <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
113
106
  ```
114
107
 
115
- #### 4. Request permissions at runtime
108
+ ---
109
+
110
+ ## Usage
116
111
 
117
- On Android 10+ you must request `ACCESS_BACKGROUND_LOCATION` **separately**, after the
118
- user has already granted foreground location. Use
119
- [`react-native-permissions`](https://github.com/zoontek/react-native-permissions) or the
120
- built-in `PermissionsAndroid` API.
112
+ ### Request permissions first
121
113
 
122
- ```js
123
- import { PermissionsAndroid, Platform } from 'react-native';
114
+ Always request OS permission before calling `start()`. We recommend [`react-native-permissions`](https://github.com/zoontek/react-native-permissions):
124
115
 
125
- async function requestLocationPermissions() {
126
- if (Platform.OS !== 'android') return;
116
+ ```ts
117
+ import { request, PERMISSIONS, RESULTS, Platform } from 'react-native-permissions';
118
+
119
+ async function requestLocationPermissions(): Promise<boolean> {
120
+ if (Platform.OS === 'ios') {
121
+ const result = await request(PERMISSIONS.IOS.LOCATION_ALWAYS);
122
+ return result === RESULTS.GRANTED;
123
+ }
127
124
 
128
- await PermissionsAndroid.request(
129
- PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION
130
- );
125
+ const fine = await request(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
126
+ if (fine !== RESULTS.GRANTED) return false;
131
127
 
132
- // Android 10+ requires a second, separate request for background
133
- if (Platform.Version >= 29) {
134
- await PermissionsAndroid.request(
135
- PermissionsAndroid.PERMISSIONS.ACCESS_BACKGROUND_LOCATION
136
- );
128
+ if (Number(Platform.Version) >= 29) {
129
+ const bg = await request(PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION);
130
+ return bg === RESULTS.GRANTED;
137
131
  }
132
+ return true;
138
133
  }
139
134
  ```
140
135
 
141
- ---
142
-
143
- ## Usage
136
+ ### Start tracking
144
137
 
145
138
  ```ts
146
- import RNGeoService from 'react-native-geo-service';
139
+ import RNGeoService from '@tsachit/react-native-geo-service';
147
140
 
148
- // 1. Configure (call once, before start)
141
+ // 1. Request OS permission
142
+ const granted = await requestLocationPermissions();
143
+ if (!granted) return;
144
+
145
+ // 2. Configure (call once before start, safe to call again to update)
149
146
  await RNGeoService.configure({
150
- minDistanceMeters: 10, // fire update every 10 metres of movement
151
- accuracy: 'balanced', // balanced accuracy = good battery
152
- stopOnAppClose: false, // keep tracking even when app is killed
153
- restartOnBoot: true, // restart on device reboot (Android)
147
+ minDistanceMeters: 10,
148
+ accuracy: 'balanced',
149
+ stopOnAppClose: false,
150
+ restartOnBoot: true,
154
151
  serviceTitle: 'Tracking active',
155
152
  serviceBody: 'Your route is being recorded.',
156
153
  });
157
154
 
158
- // 2. Start tracking
155
+ // 3. Start tracking
159
156
  await RNGeoService.start();
160
157
 
161
- // 3. Listen for location updates
158
+ // 4. Listen for updates
162
159
  const subscription = RNGeoService.onLocation((location) => {
163
160
  console.log(location.latitude, location.longitude);
164
- console.log('GPS idle:', location.isStationary);
161
+ console.log('Idle (GPS off):', location.isStationary);
162
+ });
163
+
164
+ // 5. Listen for errors
165
+ const errorSub = RNGeoService.onError((error) => {
166
+ console.error('Location error:', error.code, error.message);
165
167
  });
166
168
 
167
- // 4. Stop tracking
169
+ // 6. Stop
168
170
  await RNGeoService.stop();
169
171
 
170
- // 5. Remove listener
172
+ // 7. Clean up listeners
171
173
  subscription.remove();
174
+ errorSub.remove();
172
175
  ```
173
176
 
174
177
  ### One-time location
175
178
 
176
179
  ```ts
177
180
  const location = await RNGeoService.getCurrentLocation();
178
- console.log(location.latitude, location.longitude);
179
181
  ```
180
182
 
181
- ### Check tracking state
183
+ ### Check if tracking
182
184
 
183
185
  ```ts
184
186
  const tracking = await RNGeoService.isTracking();
185
187
  ```
186
188
 
189
+ ### Register headless task via the module
190
+
191
+ Alternatively to `AppRegistry.registerHeadlessTask`, you can use the helper:
192
+
193
+ ```ts
194
+ RNGeoService.registerHeadlessTask(async (location) => {
195
+ await sendToServer(location);
196
+ });
197
+ ```
198
+
187
199
  ---
188
200
 
189
201
  ## Configuration reference
@@ -191,7 +203,7 @@ const tracking = await RNGeoService.isTracking();
191
203
  | Option | Type | Default | Description |
192
204
  |--------|------|---------|-------------|
193
205
  | `minDistanceMeters` | `number` | `10` | Minimum metres of movement before a location update fires |
194
- | `accuracy` | `'navigation' \| 'high' \| 'balanced' \| 'low'` | `'balanced'` | Location accuracy (affects battery) |
206
+ | `accuracy` | `'navigation' \| 'high' \| 'balanced' \| 'low'` | `'balanced'` | Location accuracy higher accuracy uses more battery |
195
207
  | `stopOnAppClose` | `boolean` | `false` | Stop tracking when the app is killed |
196
208
  | `restartOnBoot` | `boolean` | `false` | Resume tracking after device reboot *(Android only)* |
197
209
  | `updateIntervalMs` | `number` | `5000` | Target ms between updates *(Android only)* |
@@ -199,49 +211,215 @@ const tracking = await RNGeoService.isTracking();
199
211
  | `serviceTitle` | `string` | `'Location Tracking'` | Foreground service notification title *(Android only)* |
200
212
  | `serviceBody` | `string` | `'Your location is being tracked...'` | Foreground service notification body *(Android only)* |
201
213
  | `backgroundTaskName` | `string` | `'GeoServiceHeadlessTask'` | HeadlessJS task name *(Android only)* |
202
- | `motionActivity` | `'other' \| 'automotiveNavigation' \| 'fitness' \| 'otherNavigation' \| 'airborne'` | `'other'` | Hints the OS about usage for power optimisation *(iOS only)* |
203
- | `autoPauseUpdates` | `boolean` | `false` | Let iOS pause updates when no movement *(iOS only)* |
204
- | `showBackgroundIndicator` | `boolean` | `false` | Show blue bar in status bar while tracking in background *(iOS only)* |
205
- | `coarseTracking` | `boolean` | `false` | Use significant-change monitoring — very battery-efficient, wakes app when terminated *(iOS only)* |
206
- | `adaptiveAccuracy` | `boolean` | `true` | Auto-drop to low-power when idle, restore on movement |
214
+ | `motionActivity` | `'other' \| 'automotiveNavigation' \| 'fitness' \| 'otherNavigation' \| 'airborne'` | `'other'` | Activity hint for iOS power optimisations *(iOS only)* |
215
+ | `autoPauseUpdates` | `boolean` | `false` | Let iOS pause updates when no movement detected *(iOS only)* |
216
+ | `showBackgroundIndicator` | `boolean` | `false` | Show blue location bar in status bar while tracking *(iOS only)* |
217
+ | `coarseTracking` | `boolean` | `false` | Use significant-change monitoring only — very battery-efficient, wakes terminated app *(iOS only)* |
218
+ | `adaptiveAccuracy` | `boolean` | `true` | Auto-drop to low-power when idle, restore on movement (biggest battery saver) |
207
219
  | `idleSpeedThreshold` | `number` | `0.5` | Speed in m/s below which a reading counts as idle |
208
- | `idleSampleCount` | `number` | `3` | Consecutive idle readings before entering low-power mode |
209
- | `debug` | `boolean` | `false` | Enable verbose native logging |
220
+ | `idleSampleCount` | `number` | `3` | Consecutive idle readings required before entering low-power mode |
221
+ | `debug` | `boolean` | `false` | Enable verbose native logging + debug notification on Android + status bar indicator on iOS |
210
222
 
211
223
  ---
212
224
 
213
- ## Battery saving tips
225
+ ## API reference
226
+
227
+ ### `configure(config)`
228
+ Apply configuration. Safe to call multiple times — subsequent calls update the running config.
229
+
230
+ ### `start()`
231
+ Start background location tracking. Always call `requestLocationPermissions()` before this.
232
+
233
+ ### `stop()`
234
+ Stop tracking and remove the foreground service (Android) / stop CLLocationManager (iOS).
235
+
236
+ ### `isTracking(): Promise<boolean>`
237
+ Returns whether tracking is currently active.
238
+
239
+ ### `getCurrentLocation(): Promise<Location>`
240
+ One-time location fetch from the last known position.
241
+
242
+ ### `onLocation(callback): GeoSubscription`
243
+ Subscribe to location updates. Call `.remove()` on the returned subscription to unsubscribe.
244
+
245
+ ### `onError(callback): GeoSubscription`
246
+ Subscribe to location errors (e.g. permission revoked mid-session).
247
+
248
+ ### `registerHeadlessTask(handler)` *(Android only)*
249
+ Register a function to handle location events when the app is not in the foreground.
250
+
251
+ ### `getBatteryInfo(): Promise<BatteryInfo>`
252
+ Returns battery and session tracking metrics. See [Debug mode](#debug-mode) below.
253
+
254
+ ### `setLocationIndicator(show: boolean)` *(iOS only)*
255
+ Show or hide the blue location indicator in the status bar at runtime. No-op on Android.
256
+
257
+ ---
258
+
259
+ ## Type reference
260
+
261
+ ### `Location`
262
+
263
+ ```ts
264
+ interface Location {
265
+ latitude: number;
266
+ longitude: number;
267
+ accuracy: number; // horizontal accuracy in metres
268
+ altitude: number;
269
+ altitudeAccuracy: number; // vertical accuracy in metres (iOS only, -1 on Android)
270
+ speed: number; // m/s, -1 if unavailable
271
+ bearing: number; // degrees 0–360, -1 if unavailable
272
+ timestamp: number; // Unix ms
273
+ isFromMockProvider?: boolean; // Android only
274
+ isStationary?: boolean; // true when adaptive accuracy has turned GPS off
275
+ }
276
+ ```
277
+
278
+ ### `BatteryInfo`
279
+
280
+ ```ts
281
+ interface BatteryInfo {
282
+ level: number; // current battery level 0–100
283
+ isCharging: boolean;
284
+ levelAtStart: number; // battery level when start() was called
285
+ drainSinceStart: number; // total % dropped since start() (whole device)
286
+
287
+ updateCount: number; // location fixes received this session
288
+ trackingElapsedSeconds: number; // seconds since start() was called
289
+ gpsActiveSeconds: number; // seconds the GPS chip was actively running
290
+ updatesPerMinute: number; // average location fixes per minute
291
+ drainRatePerHour: number; // battery drain rate in %/hr (whole device)
292
+ }
293
+ ```
294
+
295
+ ### `GeoServiceConfig`
296
+ See [Configuration reference](#configuration-reference) above.
297
+
298
+ ### `GeoSubscription`
214
299
 
215
- - Set `accuracy: 'balanced'` unless you need GPS precision.
216
- - Set `minDistanceMeters` to the minimum distance useful for your use-case (higher = fewer wakes).
217
- - On iOS, enable `coarseTracking: true` if your app only needs to know when the user
218
- has moved to a new area (~500 m). This is the most battery-efficient mode.
219
- - On Android, a higher `updateIntervalMs` (e.g. `10000`) with a reasonable `minUpdateIntervalMs`
220
- gives FusedLocationProvider more room to batch updates and use passive fixes from other apps.
221
- - Set `motionActivity: 'automotiveNavigation'` or `'fitness'` so iOS can apply activity-specific
222
- power optimisations.
223
- - Leave `adaptiveAccuracy: true` (the default) — this is the single biggest battery saving.
224
- GPS turns off completely when parked and wakes up as soon as the device moves.
300
+ ```ts
301
+ interface GeoSubscription {
302
+ remove(): void;
303
+ }
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Debug mode
309
+
310
+ Set `debug: true` in `configure()` to enable debug features:
311
+
312
+ - **iOS** — forces the blue location arrow in the status bar while tracking is active
313
+ - **Android** — notification title changes to `[DEBUG] <title>` so you can confirm the foreground service is running
314
+ - **Both** — verbose native logging via `console.log` / `Logcat`
315
+ - **Both** — a floating debug panel appears automatically showing live metrics and battery saving suggestions
316
+
317
+ ### Setup (one-time)
318
+
319
+ Add one import to the **top** of your app's `index.js`, before `AppRegistry.registerComponent`. This registers the overlay host so the panel can mount itself automatically when `debug: true` — no component needed anywhere in the app.
320
+
321
+ ```ts
322
+ // index.js
323
+ import 'react-native-gesture-handler';
324
+ import '@tsachit/react-native-geo-service/debug-panel'; // ← add this once
325
+ import { AppRegistry } from 'react-native';
326
+ import App from './App';
327
+ import { name as appName } from './app.json';
328
+
329
+ AppRegistry.registerComponent(appName, () => App);
330
+ ```
331
+
332
+ > **Why must it be in `index.js`?** React Native's `AppRegistry.setWrapperComponentProvider` must be called before `registerComponent` — the same reason `react-native-gesture-handler` must also be imported there. Placing it anywhere else (e.g. inside a hook or screen) is too late; the app root has already mounted.
333
+
334
+ ### Auto-mount debug panel
335
+
336
+ Once the setup import is in place, the panel mounts and unmounts automatically:
337
+
338
+ ```ts
339
+ // Panel appears automatically when tracking starts
340
+ await RNGeoService.configure({ debug: true, ... });
341
+ await RNGeoService.start(); // ← panel is now visible
342
+
343
+ // Panel is removed when tracking stops
344
+ await RNGeoService.stop();
345
+ ```
346
+
347
+ No `<GeoDebugPanel />` or `<GeoDebugOverlay />` needed anywhere in the component tree.
348
+
349
+ ### Debug panel behaviour
350
+
351
+ The panel is a **draggable, minimizable floating overlay** that starts minimized:
352
+
353
+ - **Tap the 📍 circle** to expand
354
+ - **Drag** by holding the striped header bar
355
+ - **Minimize** with the ⊖ button — collapses back to the 📍 circle
356
+
357
+ **Metrics shown:**
358
+
359
+ | Metric | Description |
360
+ |--------|-------------|
361
+ | Tracking for | How long the current session has been running |
362
+ | Updates | Total location fixes received |
363
+ | Updates/min | Average frequency of location updates |
364
+ | GPS active | % of session time the GPS chip was on vs idle |
365
+ | Battery now | Current device battery level |
366
+ | Drained | Total device battery % dropped since `start()` |
367
+ | Drain rate | Battery consumed per hour (total device, not just location) |
368
+
369
+ **Smart suggestions** are shown automatically:
370
+
371
+ - 🔴 Updates/min > 20 → increase `minDistanceMeters` or `updateIntervalMs`
372
+ - ⚠️ Updates/min 8–20 → consider reducing update frequency
373
+ - 🔴 GPS on > 80% of time → enable `adaptiveAccuracy` or use `coarseTracking`
374
+ - 🔴 Drain rate > 8%/hr → try `'balanced'` accuracy or longer update intervals
375
+ - ✅ All metrics in range → confirms settings are efficient
376
+
377
+ > **Note:** Battery drain is measured at the whole-device level since iOS and Android do not expose per-app battery consumption via public APIs. Use GPS active % and updates/min as the primary indicators of how much the package itself is contributing.
378
+
379
+ ### Manual usage (optional)
380
+
381
+ If you prefer to control rendering yourself, `GeoDebugPanel` and `GeoDebugOverlay` are also exported for direct use — the `debug-panel` setup import is still required for them to render correctly.
382
+
383
+ ```tsx
384
+ import { GeoDebugPanel, GeoDebugOverlay } from '@tsachit/react-native-geo-service';
385
+
386
+ // Renders anywhere in your tree — self-hides when tracking is inactive:
387
+ <GeoDebugOverlay />
388
+
389
+ // Always-visible panel with custom poll interval:
390
+ <GeoDebugPanel pollInterval={15000} />
391
+ ```
225
392
 
226
393
  ---
227
394
 
228
395
  ## Headless mode explained
229
396
 
230
397
  ### Android
231
- When the app is removed from recents (but not force-stopped), the foreground service keeps
232
- running. When a location update arrives and the React JS context is not active, the library
233
- calls `AppRegistry.startHeadlessTask` to spin up a lightweight JS runtime and invoke your
234
- registered `backgroundTaskName` handler.
398
+ When the app is removed from recents, the foreground service keeps running. When a location arrives and the React JS context is inactive, the library calls `AppRegistry.startHeadlessTask` to spin up a lightweight JS runtime and invoke your registered handler.
399
+
400
+ A `WatchdogWorker` (WorkManager, 15-min interval) monitors whether the service is still alive. On OEM devices with aggressive battery optimisation (Xiaomi, Samsung, Huawei), it restarts the service if it was killed unexpectedly.
401
+
402
+ A `BootReceiver` restarts the service after device reboot if `restartOnBoot: true`.
235
403
 
236
404
  ### iOS
237
- When the app is terminated, iOS can relaunch it silently in the background if:
238
- 1. You have the `location` background mode in `UIBackgroundModes`.
239
- 2. You use `startMonitoringSignificantLocationChanges` (`coarseTracking: true`), **or**
240
- 3. You have the _Always_ location permission and standard updates are running.
241
-
242
- Upon relaunch, `didFinishLaunchingWithOptions` is called with
243
- `UIApplicationLaunchOptionsLocationKey`, and the `CLLocationManager` delegate resumes delivering
244
- updates. The JS bridge boots and your `onLocation` listener fires normally.
405
+ When the app is terminated, iOS relaunches it silently when:
406
+ 1. `UIBackgroundModes` contains `location`, **and**
407
+ 2. `startMonitoringSignificantLocationChanges` is active (always on when tracking), **or**
408
+ 3. Standard location updates are running with the _Always_ permission
409
+
410
+ Upon relaunch, the module detects `UIApplicationLaunchOptionsLocationKey`, restores config from `NSUserDefaults`, and resumes tracking before the JS bridge has fully mounted. Any location events that arrive before JS listeners attach are buffered and flushed once `onLocation` is subscribed.
411
+
412
+ ---
413
+
414
+ ## Battery saving tips
415
+
416
+ - Use `accuracy: 'balanced'` unless you need GPS precision — cell/WiFi positioning uses far less power
417
+ - Increase `minDistanceMeters` to the minimum useful for your use case — fewer wakes = longer battery
418
+ - Leave `adaptiveAccuracy: true` (default) — this is the single biggest saving; GPS turns off completely when parked
419
+ - On iOS, use `coarseTracking: true` if ~500m granularity is acceptable — uses cell towers only
420
+ - On Android, increase `updateIntervalMs` (e.g. `10000`) to give FusedLocationProvider room to batch fixes
421
+ - Set `motionActivity: 'automotiveNavigation'` or `'fitness'` so iOS applies activity-specific optimisations
422
+ - Use the `GeoDebugPanel` to measure real-world impact and act on its suggestions
245
423
 
246
424
  ---
247
425
 
@@ -40,4 +40,5 @@ dependencies {
40
40
  implementation 'com.facebook.react:react-android:+'
41
41
  implementation 'com.google.android.gms:play-services-location:21.0.1'
42
42
  implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
43
+ implementation 'androidx.work:work-runtime-ktx:2.9.0'
43
44
  }
@@ -5,13 +5,18 @@ import android.content.BroadcastReceiver
5
5
  import android.content.Context
6
6
  import android.content.Intent
7
7
  import android.content.IntentFilter
8
+ import android.os.BatteryManager
8
9
  import android.os.Build
9
10
  import android.util.Log
10
11
  import androidx.localbroadcastmanager.content.LocalBroadcastManager
12
+ import androidx.work.ExistingPeriodicWorkPolicy
13
+ import androidx.work.PeriodicWorkRequestBuilder
14
+ import androidx.work.WorkManager
11
15
  import com.facebook.react.bridge.*
12
16
  import com.facebook.react.modules.core.DeviceEventManagerModule
13
17
  import com.google.android.gms.location.LocationServices
14
18
  import org.json.JSONObject
19
+ import java.util.concurrent.TimeUnit
15
20
 
16
21
  class GeoServiceModule(private val reactContext: ReactApplicationContext) :
17
22
  ReactContextBaseJavaModule(reactContext) {
@@ -30,6 +35,7 @@ class GeoServiceModule(private val reactContext: ReactApplicationContext) :
30
35
  private var config: GeoServiceConfig = GeoServiceConfig()
31
36
  private var listenerCount = 0
32
37
  private var locationReceiver: BroadcastReceiver? = null
38
+ private var batteryLevelAtStart: Int = 0
33
39
 
34
40
  init {
35
41
  isReactContextActive = true
@@ -103,6 +109,9 @@ class GeoServiceModule(private val reactContext: ReactApplicationContext) :
103
109
  registerLocationReceiver()
104
110
  startLocationService()
105
111
  saveTrackingState(true)
112
+ scheduleWatchdog()
113
+ val bm = reactContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
114
+ batteryLevelAtStart = bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: 0
106
115
  log("Tracking started")
107
116
  promise.resolve(null)
108
117
  } catch (e: Exception) {
@@ -115,6 +124,7 @@ class GeoServiceModule(private val reactContext: ReactApplicationContext) :
115
124
  try {
116
125
  reactContext.stopService(Intent(reactContext, LocationService::class.java))
117
126
  saveTrackingState(false)
127
+ cancelWatchdog()
118
128
  log("Tracking stopped")
119
129
  promise.resolve(null)
120
130
  } catch (e: Exception) {
@@ -144,6 +154,45 @@ class GeoServiceModule(private val reactContext: ReactApplicationContext) :
144
154
  }
145
155
  }
146
156
 
157
+ @ReactMethod
158
+ fun getBatteryInfo(promise: Promise) {
159
+ try {
160
+ val bm = reactContext.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
161
+ val level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
162
+ val status = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS)
163
+ val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
164
+ status == BatteryManager.BATTERY_STATUS_FULL
165
+ val drain = if (batteryLevelAtStart > 0) maxOf((batteryLevelAtStart - level).toDouble(), 0.0) else 0.0
166
+
167
+ val elapsedMs = if (LocationService.trackingStartTimeMs > 0)
168
+ System.currentTimeMillis() - LocationService.trackingStartTimeMs else 0L
169
+ val elapsedSec = elapsedMs / 1000.0
170
+ val gpsActiveSec = LocationService.currentGpsActiveMs / 1000.0
171
+ val updatesPerMin = if (elapsedSec > 0) LocationService.updateCount / (elapsedSec / 60.0) else 0.0
172
+ val drainRatePerHour = if (elapsedSec > 0 && drain > 0) drain / (elapsedSec / 3600.0) else 0.0
173
+
174
+ promise.resolve(Arguments.createMap().apply {
175
+ putDouble("level", level.toDouble())
176
+ putBoolean("isCharging", isCharging)
177
+ putDouble("levelAtStart", batteryLevelAtStart.toDouble())
178
+ putDouble("drainSinceStart", drain)
179
+ putInt("updateCount", LocationService.updateCount.toInt())
180
+ putDouble("trackingElapsedSeconds", elapsedSec)
181
+ putDouble("gpsActiveSeconds", gpsActiveSec)
182
+ putDouble("updatesPerMinute", updatesPerMin)
183
+ putDouble("drainRatePerHour", maxOf(drainRatePerHour, 0.0))
184
+ })
185
+ } catch (e: Exception) {
186
+ promise.reject("BATTERY_ERROR", e.message, e)
187
+ }
188
+ }
189
+
190
+ @ReactMethod
191
+ fun setLocationIndicator(@Suppress("UNUSED_PARAMETER") show: Boolean, promise: Promise) {
192
+ // No-op on Android — the foreground notification already provides the status bar indicator
193
+ promise.resolve(null)
194
+ }
195
+
147
196
  // --------------------------------------------------------------------------------------------
148
197
  // Internal helpers
149
198
  // --------------------------------------------------------------------------------------------
@@ -263,6 +312,21 @@ class GeoServiceModule(private val reactContext: ReactApplicationContext) :
263
312
  .apply()
264
313
  }
265
314
 
315
+ private fun scheduleWatchdog() {
316
+ val request = PeriodicWorkRequestBuilder<WatchdogWorker>(15, TimeUnit.MINUTES).build()
317
+ WorkManager.getInstance(reactContext).enqueueUniquePeriodicWork(
318
+ WatchdogWorker.WORK_NAME,
319
+ ExistingPeriodicWorkPolicy.KEEP,
320
+ request
321
+ )
322
+ log("Watchdog scheduled (15-min interval)")
323
+ }
324
+
325
+ private fun cancelWatchdog() {
326
+ WorkManager.getInstance(reactContext).cancelUniqueWork(WatchdogWorker.WORK_NAME)
327
+ log("Watchdog cancelled")
328
+ }
329
+
266
330
  private fun log(msg: String) {
267
331
  if (config.debug) Log.d(TAG, msg)
268
332
  }