@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.
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export {};
package/debug-panel.js ADDED
@@ -0,0 +1 @@
1
+ require('./lib/setup');