@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.
- package/README.md +265 -92
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/geoservice/GeoServiceModule.kt +64 -0
- package/android/src/main/java/com/geoservice/LocationService.kt +111 -9
- package/android/src/main/java/com/geoservice/WatchdogWorker.kt +87 -0
- package/ios/RNGeoService.m +223 -30
- package/lib/GeoDebugOverlay.d.ts +9 -0
- package/lib/GeoDebugOverlay.js +86 -0
- package/lib/GeoDebugPanel.d.ts +7 -0
- package/lib/GeoDebugPanel.js +319 -0
- package/lib/index.d.ts +16 -1
- package/lib/index.js +32 -0
- package/lib/types.d.ts +20 -0
- package/package.json +2 -1
|
@@ -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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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(
|
|
242
|
-
.setContentText(
|
|
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
|
+
}
|
package/ios/RNGeoService.m
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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:
|
|
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
|
|
252
|
-
// fix yet but will keep trying automatically. Silently ignore it
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|