@tsachit/react-native-geo-service 1.0.1 → 1.0.3

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.
@@ -7,6 +7,7 @@ import android.content.Intent
7
7
  import android.os.Build
8
8
  import android.os.IBinder
9
9
  import android.util.Log
10
+ import androidx.core.content.ContextCompat
10
11
  import androidx.localbroadcastmanager.content.LocalBroadcastManager
11
12
  import com.google.android.gms.location.*
12
13
  import org.json.JSONObject
@@ -24,6 +25,21 @@ class LocationService : Service() {
24
25
 
25
26
  var isRunning = false
26
27
  private set
28
+
29
+ // Session tracking metrics (readable by GeoServiceModule)
30
+ var updateCount: Long = 0
31
+ private set
32
+ var trackingStartTimeMs: Long = 0
33
+ private set
34
+ // Accumulated GPS-on milliseconds (excludes current open window)
35
+ private var gpsAccumulatedMs: Long = 0
36
+ // When the current GPS-on window started (0 = GPS currently idle)
37
+ private var gpsActiveWindowStartMs: Long = 0
38
+
39
+ /** Total GPS-active ms including any currently open window */
40
+ val currentGpsActiveMs: Long
41
+ get() = gpsAccumulatedMs +
42
+ if (gpsActiveWindowStartMs > 0) System.currentTimeMillis() - gpsActiveWindowStartMs else 0L
27
43
  }
28
44
 
29
45
  private lateinit var fusedLocationClient: FusedLocationProviderClient
@@ -44,12 +60,30 @@ class LocationService : Service() {
44
60
  }
45
61
 
46
62
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
47
- val bundle = intent?.getBundleExtra("config")
48
- config = GeoServiceConfig.fromBundle(bundle)
63
+ config = if (intent != null) {
64
+ GeoServiceConfig.fromBundle(intent.getBundleExtra("config"))
65
+ } else {
66
+ // START_STICKY restart after force-kill: intent is null, restore from SharedPreferences
67
+ Log.d(TAG, "START_STICKY restart detected — restoring config from SharedPreferences")
68
+ configFromSharedPreferences()
69
+ }
70
+
71
+ // Stop immediately if location permission was revoked while we were away
72
+ if (!hasLocationPermission()) {
73
+ Log.e(TAG, "Location permission not granted — stopping service")
74
+ stopSelf()
75
+ return START_NOT_STICKY
76
+ }
49
77
 
50
78
  slowReadingCount = 0
51
79
  isIdle = false
52
80
 
81
+ // Reset session metrics
82
+ updateCount = 0
83
+ trackingStartTimeMs = System.currentTimeMillis()
84
+ gpsAccumulatedMs = 0
85
+ gpsActiveWindowStartMs = System.currentTimeMillis() // GPS starts active
86
+
53
87
  log("Starting — adaptiveAccuracy=${config.adaptiveAccuracy}, accuracy=${config.accuracy}")
54
88
  startForeground(NOTIFICATION_ID, buildNotification())
55
89
  startLocationUpdates(idleOverride = false)
@@ -58,6 +92,63 @@ class LocationService : Service() {
58
92
  return START_STICKY
59
93
  }
60
94
 
95
+ // Called when the user swipes the app away from the recents screen.
96
+ // By default the foreground service keeps running — which is correct for
97
+ // always-on tracking. If stopOnAppClose=true we honour the user's intent.
98
+ override fun onTaskRemoved(rootIntent: Intent?) {
99
+ super.onTaskRemoved(rootIntent)
100
+ val prefs = getSharedPreferences(BootReceiver.PREFS_NAME, Context.MODE_PRIVATE)
101
+ val shouldStop = try {
102
+ val json = prefs.getString("configBundle", null)
103
+ json?.let { org.json.JSONObject(it).optBoolean("stopOnAppClose", false) } ?: false
104
+ } catch (e: Exception) { false }
105
+
106
+ if (shouldStop) {
107
+ log("App removed from recents — stopOnAppClose=true, stopping service")
108
+ prefs.edit().putBoolean(BootReceiver.KEY_IS_TRACKING, false).apply()
109
+ stopSelf()
110
+ } else {
111
+ log("App removed from recents — continuing background tracking")
112
+ }
113
+ }
114
+
115
+ private fun hasLocationPermission(): Boolean {
116
+ val fine = ContextCompat.checkSelfPermission(
117
+ this, android.Manifest.permission.ACCESS_FINE_LOCATION
118
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
119
+ val coarse = ContextCompat.checkSelfPermission(
120
+ this, android.Manifest.permission.ACCESS_COARSE_LOCATION
121
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
122
+ return fine || coarse
123
+ }
124
+
125
+ private fun configFromSharedPreferences(): GeoServiceConfig {
126
+ val prefs = getSharedPreferences(BootReceiver.PREFS_NAME, Context.MODE_PRIVATE)
127
+ val configJson = prefs.getString("configBundle", null) ?: return GeoServiceConfig()
128
+ return try {
129
+ val json = org.json.JSONObject(configJson)
130
+ val bundle = android.os.Bundle().apply {
131
+ putFloat("minDistanceMeters", json.optDouble("minDistanceMeters", 10.0).toFloat())
132
+ putString("accuracy", json.optString("accuracy", "balanced"))
133
+ putBoolean("stopOnAppClose", json.optBoolean("stopOnAppClose", false))
134
+ putBoolean("restartOnBoot", json.optBoolean("restartOnBoot", false))
135
+ putLong("updateIntervalMs", json.optLong("updateIntervalMs", 5000L))
136
+ putLong("minUpdateIntervalMs", json.optLong("minUpdateIntervalMs", 2000L))
137
+ putString("serviceTitle", json.optString("serviceTitle", "Location Tracking"))
138
+ putString("serviceBody", json.optString("serviceBody", "Your location is being tracked in the background."))
139
+ putString("backgroundTaskName", json.optString("backgroundTaskName", "GeoServiceHeadlessTask"))
140
+ putBoolean("adaptiveAccuracy", json.optBoolean("adaptiveAccuracy", true))
141
+ putFloat("idleSpeedThreshold", json.optDouble("idleSpeedThreshold", 0.5).toFloat())
142
+ putInt("idleSampleCount", json.optInt("idleSampleCount", 3))
143
+ putBoolean("debug", json.optBoolean("debug", false))
144
+ }
145
+ GeoServiceConfig.fromBundle(bundle)
146
+ } catch (e: Exception) {
147
+ Log.e(TAG, "Failed to restore config from SharedPreferences: ${e.message}")
148
+ GeoServiceConfig()
149
+ }
150
+ }
151
+
61
152
  // ---------------------------------------------------------------------------
62
153
  // Location updates
63
154
  // ---------------------------------------------------------------------------
@@ -109,6 +200,7 @@ class LocationService : Service() {
109
200
  // ---------------------------------------------------------------------------
110
201
 
111
202
  private fun handleLocation(location: android.location.Location) {
203
+ updateCount++
112
204
  if (config.adaptiveAccuracy) {
113
205
  evaluateMotionState(location)
114
206
  }
@@ -123,6 +215,11 @@ class LocationService : Service() {
123
215
  if (!isIdle && slowReadingCount >= config.idleSampleCount) {
124
216
  isIdle = true
125
217
  slowReadingCount = 0
218
+ // Accumulate GPS-on time before going idle
219
+ if (gpsActiveWindowStartMs > 0) {
220
+ gpsAccumulatedMs += System.currentTimeMillis() - gpsActiveWindowStartMs
221
+ gpsActiveWindowStartMs = 0
222
+ }
126
223
  log("Device idle — switching to LOW_POWER (GPS off)")
127
224
  startLocationUpdates(idleOverride = true)
128
225
  }
@@ -130,6 +227,7 @@ class LocationService : Service() {
130
227
  if (isIdle) {
131
228
  isIdle = false
132
229
  slowReadingCount = 0
230
+ gpsActiveWindowStartMs = System.currentTimeMillis() // GPS back on
133
231
  log("Movement detected — restoring ${config.accuracy} accuracy")
134
232
  startLocationUpdates(idleOverride = false)
135
233
  } else {
@@ -195,11 +293,12 @@ class LocationService : Service() {
195
293
  putExtra("location", locationJson)
196
294
  putExtra("taskName", config.backgroundTaskName)
197
295
  }
198
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
199
- startForegroundService(intent)
200
- } else {
201
- startService(intent)
202
- }
296
+ // Always use startService — HeadlessJsTaskService does NOT call startForeground().
297
+ // Using startForegroundService() here would crash with
298
+ // ForegroundServiceDidNotStartInTimeException after 5 seconds on Android O+.
299
+ // This is safe because LocationService itself is a foreground service, which
300
+ // allows it to start background services regardless of app state.
301
+ startService(intent)
203
302
  }
204
303
 
205
304
  // ---------------------------------------------------------------------------
@@ -237,9 +336,12 @@ class LocationService : Service() {
237
336
  PendingIntent.FLAG_UPDATE_CURRENT
238
337
  val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags)
239
338
 
339
+ val title = if (config.debug) "[DEBUG] ${config.serviceTitle}" else config.serviceTitle
340
+ val body = if (config.debug) "Tracking active — debug mode on" else config.serviceBody
341
+
240
342
  return builder
241
- .setContentTitle(config.serviceTitle)
242
- .setContentText(config.serviceBody)
343
+ .setContentTitle(title)
344
+ .setContentText(body)
243
345
  .setSmallIcon(android.R.drawable.ic_menu_mylocation)
244
346
  .setOngoing(true)
245
347
  .setContentIntent(pendingIntent)
@@ -0,0 +1,87 @@
1
+ package com.geoservice
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.os.Build
6
+ import android.util.Log
7
+ import androidx.work.CoroutineWorker
8
+ import androidx.work.WorkerParameters
9
+
10
+ /**
11
+ * Periodic watchdog that detects when the LocationService was killed by the OS
12
+ * (common on Xiaomi, Samsung, Huawei with aggressive battery optimization) and
13
+ * restarts it using the config persisted in SharedPreferences.
14
+ *
15
+ * Scheduled every 15 minutes (WorkManager minimum). If the service is already
16
+ * running nothing happens. If tracking was expected but the service is dead,
17
+ * it is restarted with the user's original config.
18
+ */
19
+ class WatchdogWorker(
20
+ private val appContext: Context,
21
+ workerParams: WorkerParameters
22
+ ) : CoroutineWorker(appContext, workerParams) {
23
+
24
+ companion object {
25
+ const val TAG = "GeoService:Watchdog"
26
+ const val WORK_NAME = "GeoServiceWatchdog"
27
+ }
28
+
29
+ override suspend fun doWork(): Result {
30
+ val prefs = appContext.getSharedPreferences(
31
+ BootReceiver.PREFS_NAME, Context.MODE_PRIVATE
32
+ )
33
+ val isTrackingExpected = prefs.getBoolean(BootReceiver.KEY_IS_TRACKING, false)
34
+
35
+ if (!isTrackingExpected) {
36
+ Log.d(TAG, "Tracking not expected — nothing to do")
37
+ return Result.success()
38
+ }
39
+
40
+ if (LocationService.isRunning) {
41
+ Log.d(TAG, "Service is running — no action needed")
42
+ return Result.success()
43
+ }
44
+
45
+ Log.w(TAG, "Service not running but tracking was expected — restarting")
46
+
47
+ val configJson = prefs.getString("configBundle", null)
48
+ val serviceIntent = Intent(appContext, LocationService::class.java)
49
+
50
+ if (configJson != null) {
51
+ try {
52
+ val json = org.json.JSONObject(configJson)
53
+ val bundle = android.os.Bundle().apply {
54
+ putFloat("minDistanceMeters", json.optDouble("minDistanceMeters", 10.0).toFloat())
55
+ putString("accuracy", json.optString("accuracy", "balanced"))
56
+ putBoolean("stopOnAppClose", json.optBoolean("stopOnAppClose", false))
57
+ putBoolean("restartOnBoot", json.optBoolean("restartOnBoot", false))
58
+ putLong("updateIntervalMs", json.optLong("updateIntervalMs", 5000L))
59
+ putLong("minUpdateIntervalMs", json.optLong("minUpdateIntervalMs", 2000L))
60
+ putString("serviceTitle", json.optString("serviceTitle", "Location Tracking"))
61
+ putString("serviceBody", json.optString("serviceBody", "Your location is being tracked in the background."))
62
+ putString("backgroundTaskName", json.optString("backgroundTaskName", "GeoServiceHeadlessTask"))
63
+ putBoolean("adaptiveAccuracy", json.optBoolean("adaptiveAccuracy", true))
64
+ putFloat("idleSpeedThreshold", json.optDouble("idleSpeedThreshold", 0.5).toFloat())
65
+ putInt("idleSampleCount", json.optInt("idleSampleCount", 3))
66
+ putBoolean("debug", json.optBoolean("debug", false))
67
+ }
68
+ serviceIntent.putExtra("config", bundle)
69
+ } catch (e: Exception) {
70
+ Log.e(TAG, "Failed to parse config — starting with defaults: ${e.message}")
71
+ }
72
+ }
73
+
74
+ return try {
75
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
76
+ appContext.startForegroundService(serviceIntent)
77
+ } else {
78
+ appContext.startService(serviceIntent)
79
+ }
80
+ Log.d(TAG, "Service restarted successfully")
81
+ Result.success()
82
+ } catch (e: Exception) {
83
+ Log.e(TAG, "Failed to restart service: ${e.message}")
84
+ Result.retry()
85
+ }
86
+ }
87
+ }
@@ -1,5 +1,6 @@
1
1
  #import "RNGeoService.h"
2
2
  #import <React/RCTLog.h>
3
+ #import <UIKit/UIKit.h>
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // CLActivityType helper
@@ -33,6 +34,9 @@ static CLLocationAccuracy accuracyFromString(NSString *accuracy) {
33
34
  @property (nonatomic, strong) CLLocationManager *locationManager;
34
35
  @property (nonatomic, strong) NSDictionary *config;
35
36
 
37
+ // Locations buffered while JS listeners are not yet attached (background relaunch)
38
+ @property (nonatomic, strong) NSMutableArray<NSDictionary *> *pendingLocations;
39
+
36
40
  @property (nonatomic, assign) BOOL isTracking;
37
41
  @property (nonatomic, assign) BOOL hasListeners;
38
42
  @property (nonatomic, assign) BOOL coarseTracking;
@@ -45,6 +49,15 @@ static CLLocationAccuracy accuracyFromString(NSString *accuracy) {
45
49
  @property (nonatomic, assign) NSInteger slowReadingCount;
46
50
  @property (nonatomic, assign) BOOL isIdle;
47
51
 
52
+ // Battery tracking
53
+ @property (nonatomic, assign) float batteryLevelAtStart;
54
+
55
+ // Session tracking metrics
56
+ @property (nonatomic, assign) NSInteger updateCount;
57
+ @property (nonatomic, strong) NSDate *trackingStartTime;
58
+ @property (nonatomic, assign) NSTimeInterval gpsActiveSeconds; // accumulated GPS-on time
59
+ @property (nonatomic, strong) NSDate *gpsActiveStart; // when current GPS-on window started
60
+
48
61
  @end
49
62
 
50
63
  @implementation RNGeoService
@@ -59,6 +72,56 @@ RCT_EXPORT_MODULE();
59
72
  return dispatch_get_main_queue();
60
73
  }
61
74
 
75
+ // ---------------------------------------------------------------------------
76
+ // Init — auto-resume tracking if app was relaunched from terminated state
77
+ //
78
+ // iOS can relaunch a terminated app when startMonitoringSignificantLocationChanges
79
+ // is active. When this happens, React Native creates a fresh module instance.
80
+ // We detect this via NSUserDefaults and immediately resume tracking so that
81
+ // location updates are not lost during the relaunch window.
82
+ // ---------------------------------------------------------------------------
83
+ - (instancetype)init {
84
+ if (self = [super init]) {
85
+ self.pendingLocations = [NSMutableArray array];
86
+
87
+ NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
88
+ BOOL wasTracking = [defaults boolForKey:@"GeoServiceIsTracking"];
89
+
90
+ if (wasTracking) {
91
+ // Restore persisted config
92
+ NSData *configData = [defaults objectForKey:@"GeoServiceConfig"];
93
+ if (configData) {
94
+ NSDictionary *restoredConfig = [NSPropertyListSerialization
95
+ propertyListWithData:configData options:0 format:nil error:nil];
96
+ if (restoredConfig) {
97
+ self.config = restoredConfig;
98
+ self.coarseTracking = [restoredConfig[@"coarseTracking"] boolValue];
99
+ self.debugMode = [restoredConfig[@"debug"] boolValue];
100
+ self.adaptiveAccuracy = restoredConfig[@"adaptiveAccuracy"]
101
+ ? [restoredConfig[@"adaptiveAccuracy"] boolValue] : YES;
102
+ self.idleSpeedThreshold = restoredConfig[@"idleSpeedThreshold"]
103
+ ? [restoredConfig[@"idleSpeedThreshold"] floatValue] : 0.5f;
104
+ self.idleSampleCount = restoredConfig[@"idleSampleCount"]
105
+ ? [restoredConfig[@"idleSampleCount"] integerValue] : 3;
106
+ }
107
+ }
108
+
109
+ [self applyConfigToLocationManager];
110
+
111
+ // Significant changes is always running alongside standard updates —
112
+ // it is the only mechanism that can wake a terminated app and costs
113
+ // almost nothing in battery (cell towers, not GPS).
114
+ [self.locationManager startMonitoringSignificantLocationChanges];
115
+ if (!self.coarseTracking) {
116
+ [self.locationManager startUpdatingLocation];
117
+ }
118
+ self.isTracking = YES;
119
+ if (self.debugMode) RCTLogInfo(@"[RNGeoService] Auto-resumed tracking after app relaunch");
120
+ }
121
+ }
122
+ return self;
123
+ }
124
+
62
125
  // ---------------------------------------------------------------------------
63
126
  // Supported events
64
127
  // ---------------------------------------------------------------------------
@@ -66,8 +129,24 @@ RCT_EXPORT_MODULE();
66
129
  return @[@"onLocation", @"onError"];
67
130
  }
68
131
 
69
- - (void)startObserving { self.hasListeners = YES; }
70
- - (void)stopObserving { self.hasListeners = NO; }
132
+ // Drain any locations that arrived before JS listeners were attached.
133
+ // This is the normal case during a background relaunch from terminated state:
134
+ // CLLocationManager fires before the React component tree has mounted.
135
+ - (void)startObserving {
136
+ self.hasListeners = YES;
137
+ if (self.pendingLocations.count > 0) {
138
+ if (self.debugMode) {
139
+ RCTLogInfo(@"[RNGeoService] Draining %lu buffered location(s) to JS",
140
+ (unsigned long)self.pendingLocations.count);
141
+ }
142
+ for (NSDictionary *loc in self.pendingLocations) {
143
+ [self sendEventWithName:@"onLocation" body:loc];
144
+ }
145
+ [self.pendingLocations removeAllObjects];
146
+ }
147
+ }
148
+
149
+ - (void)stopObserving { self.hasListeners = NO; }
71
150
 
72
151
  // ---------------------------------------------------------------------------
73
152
  // Lazy CLLocationManager
@@ -86,7 +165,7 @@ RCT_EXPORT_MODULE();
86
165
  RCT_EXPORT_METHOD(configure:(NSDictionary *)options
87
166
  resolve:(RCTPromiseResolveBlock)resolve
88
167
  reject:(RCTPromiseRejectBlock)reject) {
89
- self.config = options;
168
+ self.config = options;
90
169
  self.coarseTracking = [options[@"coarseTracking"] boolValue];
91
170
  self.debugMode = [options[@"debug"] boolValue];
92
171
  self.adaptiveAccuracy = options[@"adaptiveAccuracy"] ? [options[@"adaptiveAccuracy"] boolValue] : YES;
@@ -95,6 +174,19 @@ RCT_EXPORT_METHOD(configure:(NSDictionary *)options
95
174
  self.slowReadingCount = 0;
96
175
  self.isIdle = NO;
97
176
 
177
+ // Persist config so it survives app termination and can be restored on
178
+ // background relaunch triggered by significant location changes.
179
+ NSError *serializeError = nil;
180
+ NSData *configData = [NSPropertyListSerialization
181
+ dataWithPropertyList:options
182
+ format:NSPropertyListBinaryFormat_v1_0
183
+ options:0
184
+ error:&serializeError];
185
+ if (configData && !serializeError) {
186
+ [[NSUserDefaults standardUserDefaults] setObject:configData forKey:@"GeoServiceConfig"];
187
+ [[NSUserDefaults standardUserDefaults] synchronize];
188
+ }
189
+
98
190
  [self applyConfigToLocationManager];
99
191
 
100
192
  if (self.debugMode) RCTLogInfo(@"[RNGeoService] Config applied: %@", options);
@@ -117,7 +209,7 @@ RCT_EXPORT_METHOD(configure:(NSDictionary *)options
117
209
  self.locationManager.pausesLocationUpdatesAutomatically = autoPause;
118
210
 
119
211
  if (@available(iOS 11.0, *)) {
120
- BOOL bgIndicator = [cfg[@"showBackgroundIndicator"] boolValue];
212
+ BOOL bgIndicator = self.debugMode ? YES : [cfg[@"showBackgroundIndicator"] boolValue];
121
213
  self.locationManager.showsBackgroundLocationIndicator = bgIndicator;
122
214
  }
123
215
 
@@ -131,30 +223,44 @@ RCT_EXPORT_METHOD(start:(RCTPromiseResolveBlock)resolve
131
223
  reject:(RCTPromiseRejectBlock)reject) {
132
224
  CLAuthorizationStatus status = [CLLocationManager authorizationStatus];
133
225
 
226
+ // If permission is denied or restricted, resolve without starting.
227
+ // The app is responsible for requesting OS permission (via react-native-permissions)
228
+ // before calling start(). If denied, the didChangeAuthorizationStatus delegate
229
+ // will handle cleanup.
134
230
  if (status == kCLAuthorizationStatusDenied ||
135
231
  status == kCLAuthorizationStatusRestricted) {
136
- reject(@"PERMISSION_DENIED", @"Location permission denied. Request 'Always' permission before calling start().", nil);
232
+ resolve(nil);
137
233
  return;
138
234
  }
139
235
 
140
- if (status == kCLAuthorizationStatusNotDetermined) {
141
- [self.locationManager requestAlwaysAuthorization];
142
- }
143
-
144
236
  [self applyConfigToLocationManager];
145
237
 
238
+ // Significant changes MUST always run alongside standard updates.
239
+ // It is the only iOS mechanism that can relaunch a terminated app —
240
+ // and it uses cell towers (not GPS), so battery cost is negligible.
241
+ [self.locationManager startMonitoringSignificantLocationChanges];
242
+
146
243
  if (self.coarseTracking) {
147
- [self.locationManager startMonitoringSignificantLocationChanges];
148
- if (self.debugMode) RCTLogInfo(@"[RNGeoService] Coarse (significant-change) tracking started");
244
+ if (self.debugMode) RCTLogInfo(@"[RNGeoService] Coarse (significant-change only) tracking started");
149
245
  } else {
150
246
  [self.locationManager startUpdatingLocation];
151
- if (self.debugMode) RCTLogInfo(@"[RNGeoService] Standard tracking started");
247
+ if (self.debugMode) RCTLogInfo(@"[RNGeoService] Standard tracking started (+ significant changes for background wake)");
152
248
  }
153
249
 
154
250
  self.isTracking = YES;
155
251
  [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"GeoServiceIsTracking"];
156
252
  [[NSUserDefaults standardUserDefaults] synchronize];
157
253
 
254
+ // Record battery level at tracking start for drain calculation
255
+ [UIDevice currentDevice].batteryMonitoringEnabled = YES;
256
+ self.batteryLevelAtStart = [UIDevice currentDevice].batteryLevel;
257
+
258
+ // Reset session metrics
259
+ self.updateCount = 0;
260
+ self.gpsActiveSeconds = 0;
261
+ self.trackingStartTime = [NSDate date];
262
+ self.gpsActiveStart = [NSDate date]; // GPS starts active
263
+
158
264
  resolve(nil);
159
265
  }
160
266
 
@@ -163,14 +269,12 @@ RCT_EXPORT_METHOD(start:(RCTPromiseResolveBlock)resolve
163
269
  // ---------------------------------------------------------------------------
164
270
  RCT_EXPORT_METHOD(stop:(RCTPromiseResolveBlock)resolve
165
271
  reject:(RCTPromiseRejectBlock)reject) {
166
- if (self.coarseTracking) {
167
- [self.locationManager stopMonitoringSignificantLocationChanges];
168
- } else {
169
- [self.locationManager stopUpdatingLocation];
170
- }
272
+ [self.locationManager stopUpdatingLocation];
273
+ [self.locationManager stopMonitoringSignificantLocationChanges];
171
274
 
172
275
  self.isTracking = NO;
173
276
  [[NSUserDefaults standardUserDefaults] setBool:NO forKey:@"GeoServiceIsTracking"];
277
+ [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"GeoServiceConfig"];
174
278
  [[NSUserDefaults standardUserDefaults] synchronize];
175
279
 
176
280
  if (self.debugMode) RCTLogInfo(@"[RNGeoService] Tracking stopped");
@@ -195,6 +299,55 @@ RCT_EXPORT_METHOD(getCurrentLocation:(RCTPromiseResolveBlock)resolve
195
299
  }
196
300
  }
197
301
 
302
+ // ---------------------------------------------------------------------------
303
+ // getBatteryInfo() / setLocationIndicator()
304
+ // ---------------------------------------------------------------------------
305
+ RCT_EXPORT_METHOD(getBatteryInfo:(RCTPromiseResolveBlock)resolve
306
+ reject:(RCTPromiseRejectBlock)reject) {
307
+ [UIDevice currentDevice].batteryMonitoringEnabled = YES;
308
+ float current = [UIDevice currentDevice].batteryLevel;
309
+ UIDeviceBatteryState state = [UIDevice currentDevice].batteryState;
310
+ BOOL isCharging = state == UIDeviceBatteryStateCharging || state == UIDeviceBatteryStateFull;
311
+ float drain = (self.batteryLevelAtStart > 0 && current > 0)
312
+ ? (self.batteryLevelAtStart - current) * 100.0f : 0.0f;
313
+
314
+ // Elapsed session time
315
+ NSTimeInterval elapsed = self.trackingStartTime
316
+ ? [[NSDate date] timeIntervalSinceDate:self.trackingStartTime] : 0;
317
+
318
+ // GPS active = accumulated time + current window (if GPS is on right now)
319
+ NSTimeInterval gpsActive = self.gpsActiveSeconds;
320
+ if (!self.isIdle && self.gpsActiveStart) {
321
+ gpsActive += [[NSDate date] timeIntervalSinceDate:self.gpsActiveStart];
322
+ }
323
+
324
+ double updatesPerMinute = (elapsed > 0)
325
+ ? (self.updateCount / (elapsed / 60.0)) : 0;
326
+ double drainRatePerHour = (elapsed > 0 && drain > 0)
327
+ ? (drain / (elapsed / 3600.0)) : 0;
328
+
329
+ resolve(@{
330
+ @"level": @(current * 100.0f),
331
+ @"isCharging": @(isCharging),
332
+ @"levelAtStart": @(self.batteryLevelAtStart * 100.0f),
333
+ @"drainSinceStart": @(MAX(drain, 0.0f)),
334
+ @"updateCount": @(self.updateCount),
335
+ @"trackingElapsedSeconds": @(elapsed),
336
+ @"gpsActiveSeconds": @(gpsActive),
337
+ @"updatesPerMinute": @(updatesPerMinute),
338
+ @"drainRatePerHour": @(MAX(drainRatePerHour, 0.0))
339
+ });
340
+ }
341
+
342
+ RCT_EXPORT_METHOD(setLocationIndicator:(BOOL)show
343
+ resolve:(RCTPromiseResolveBlock)resolve
344
+ reject:(RCTPromiseRejectBlock)reject) {
345
+ if (@available(iOS 11.0, *)) {
346
+ self.locationManager.showsBackgroundLocationIndicator = show;
347
+ }
348
+ resolve(nil);
349
+ }
350
+
198
351
  // ---------------------------------------------------------------------------
199
352
  // CLLocationManagerDelegate
200
353
  // ---------------------------------------------------------------------------
@@ -203,8 +356,11 @@ RCT_EXPORT_METHOD(getCurrentLocation:(RCTPromiseResolveBlock)resolve
203
356
  CLLocation *location = [locations lastObject];
204
357
  if (!location) return;
205
358
 
359
+ self.updateCount++;
360
+
206
361
  if (self.debugMode) {
207
- RCTLogInfo(@"[RNGeoService] Location: %f, %f (±%.0fm) speed=%.1fm/s",
362
+ RCTLogInfo(@"[RNGeoService] Location #%ld: %f, %f (±%.0fm) speed=%.1fm/s",
363
+ (long)self.updateCount,
208
364
  location.coordinate.latitude,
209
365
  location.coordinate.longitude,
210
366
  location.horizontalAccuracy,
@@ -215,8 +371,18 @@ RCT_EXPORT_METHOD(getCurrentLocation:(RCTPromiseResolveBlock)resolve
215
371
  [self evaluateMotionState:location];
216
372
  }
217
373
 
374
+ NSDictionary *locationDict = [self locationToDictionary:location];
375
+
218
376
  if (self.hasListeners) {
219
- [self sendEventWithName:@"onLocation" body:[self locationToDictionary:location]];
377
+ [self sendEventWithName:@"onLocation" body:locationDict];
378
+ } else {
379
+ // Buffer the location — JS listeners haven't attached yet.
380
+ // This is normal during background relaunch: CLLocationManager fires
381
+ // before the React component tree has had time to mount.
382
+ // Events are drained in startObserving() once a listener attaches.
383
+ if (self.pendingLocations.count < 10) {
384
+ [self.pendingLocations addObject:locationDict];
385
+ }
220
386
  }
221
387
  }
222
388
 
@@ -229,6 +395,11 @@ RCT_EXPORT_METHOD(getCurrentLocation:(RCTPromiseResolveBlock)resolve
229
395
  if (!self.isIdle && self.slowReadingCount >= self.idleSampleCount) {
230
396
  self.isIdle = YES;
231
397
  self.slowReadingCount = 0;
398
+ // Accumulate GPS-on time before going idle
399
+ if (self.gpsActiveStart) {
400
+ self.gpsActiveSeconds += [[NSDate date] timeIntervalSinceDate:self.gpsActiveStart];
401
+ self.gpsActiveStart = nil;
402
+ }
232
403
  // Reduce accuracy — CoreLocation stops requesting GPS
233
404
  self.locationManager.desiredAccuracy = kCLLocationAccuracyKilometer;
234
405
  self.locationManager.distanceFilter = 50.0;
@@ -238,6 +409,7 @@ RCT_EXPORT_METHOD(getCurrentLocation:(RCTPromiseResolveBlock)resolve
238
409
  if (self.isIdle) {
239
410
  self.isIdle = NO;
240
411
  self.slowReadingCount = 0;
412
+ self.gpsActiveStart = [NSDate date]; // GPS back on
241
413
  [self applyConfigToLocationManager];
242
414
  if (self.debugMode) RCTLogInfo(@"[RNGeoService] Movement detected — accuracy restored");
243
415
  } else {
@@ -248,20 +420,21 @@ RCT_EXPORT_METHOD(getCurrentLocation:(RCTPromiseResolveBlock)resolve
248
420
 
249
421
  - (void)locationManager:(CLLocationManager *)manager
250
422
  didFailWithError:(NSError *)error {
251
- // kCLErrorLocationUnknown (code 0) is transient — CoreLocation couldn't get a
252
- // fix yet but will keep trying automatically. Silently ignore it so we don't
253
- // surface a noisy error to the app before the GPS has warmed up.
423
+ // kCLErrorLocationUnknown (code 0) is transient — CoreLocation hasn't acquired
424
+ // a fix yet but will keep trying automatically. Silently ignore it.
254
425
  if ([error.domain isEqualToString:kCLErrorDomain] && error.code == kCLErrorLocationUnknown) {
255
426
  if (self.debugMode) RCTLogInfo(@"[RNGeoService] Location unknown (transient) — waiting for GPS fix");
256
427
  return;
257
428
  }
258
429
 
259
- // kCLErrorDenied means the user revoked location permission this is a real
260
- // error worth surfacing, and we should stop tracking to avoid repeated failures.
430
+ // kCLErrorDenied: user revoked permission. Stop everything and clear state.
261
431
  if ([error.domain isEqualToString:kCLErrorDomain] && error.code == kCLErrorDenied) {
262
432
  RCTLogWarn(@"[RNGeoService] Location permission denied — stopping tracking");
263
433
  [self.locationManager stopUpdatingLocation];
264
434
  [self.locationManager stopMonitoringSignificantLocationChanges];
435
+ self.isTracking = NO;
436
+ [[NSUserDefaults standardUserDefaults] setBool:NO forKey:@"GeoServiceIsTracking"];
437
+ [[NSUserDefaults standardUserDefaults] synchronize];
265
438
  } else {
266
439
  RCTLogError(@"[RNGeoService] Location error: %@", error.localizedDescription);
267
440
  }
@@ -276,18 +449,38 @@ RCT_EXPORT_METHOD(getCurrentLocation:(RCTPromiseResolveBlock)resolve
276
449
 
277
450
  - (void)locationManager:(CLLocationManager *)manager
278
451
  didChangeAuthorizationStatus:(CLAuthorizationStatus)status {
279
- if (self.debugMode) RCTLogInfo(@"[RNGeoService] Auth status: %d", status);
452
+ if (self.debugMode) RCTLogInfo(@"[RNGeoService] Auth status changed: %d", status);
453
+
454
+ // Permission was revoked while tracking — stop and notify JS
455
+ if (status == kCLAuthorizationStatusDenied || status == kCLAuthorizationStatusRestricted) {
456
+ if (self.isTracking) {
457
+ [self.locationManager stopUpdatingLocation];
458
+ [self.locationManager stopMonitoringSignificantLocationChanges];
459
+ self.isTracking = NO;
460
+ [[NSUserDefaults standardUserDefaults] setBool:NO forKey:@"GeoServiceIsTracking"];
461
+ [[NSUserDefaults standardUserDefaults] synchronize];
462
+ if (self.hasListeners) {
463
+ [self sendEventWithName:@"onError" body:@{
464
+ @"code": @(kCLErrorDenied),
465
+ @"message": @"Location permission was revoked. Please re-enable in Settings."
466
+ }];
467
+ }
468
+ }
469
+ return;
470
+ }
280
471
 
281
- // Resume tracking after background relaunch (e.g. significant location change)
282
- if (status == kCLAuthorizationStatusAuthorizedAlways &&
472
+ // Permission granted after background relaunch resume if we were tracking before
473
+ if ((status == kCLAuthorizationStatusAuthorizedAlways ||
474
+ status == kCLAuthorizationStatusAuthorizedWhenInUse) &&
475
+ !self.isTracking &&
283
476
  [[NSUserDefaults standardUserDefaults] boolForKey:@"GeoServiceIsTracking"]) {
284
477
  [self applyConfigToLocationManager];
285
- if (self.coarseTracking) {
286
- [self.locationManager startMonitoringSignificantLocationChanges];
287
- } else {
478
+ [self.locationManager startMonitoringSignificantLocationChanges];
479
+ if (!self.coarseTracking) {
288
480
  [self.locationManager startUpdatingLocation];
289
481
  }
290
482
  self.isTracking = YES;
483
+ if (self.debugMode) RCTLogInfo(@"[RNGeoService] Tracking resumed after auth grant");
291
484
  }
292
485
  }
293
486