react-native-nitro-geolocation 1.3.1 → 1.3.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.
@@ -116,9 +116,10 @@ class NitroGeolocation(
116
116
  success: (PermissionStatus) -> Unit,
117
117
  error: ((LocationError) -> Unit)?
118
118
  ): Unit {
119
- // Check if already determined
119
+ // Android reports missing location permission as DENIED even before a
120
+ // runtime prompt has been shown, so denied must still request.
120
121
  val currentStatus = getCurrentPermissionStatus()
121
- if (currentStatus != PermissionStatus.UNDETERMINED) {
122
+ if (currentStatus == PermissionStatus.GRANTED) {
122
123
  success(currentStatus)
123
124
  return
124
125
  }
@@ -6,6 +6,7 @@ import com.margelo.nitro.nitrogeolocation.StoredBackgroundLocation
6
6
  import java.io.OutputStreamWriter
7
7
  import java.net.HttpURLConnection
8
8
  import java.net.URL
9
+ import kotlin.random.Random
9
10
 
10
11
  internal class AndroidBackgroundHttpSync {
11
12
  fun uploadLocationsWithRetry(
@@ -43,7 +44,7 @@ internal class AndroidBackgroundHttpSync {
43
44
  lastError = error.message ?: "HTTP sync failed"
44
45
  }
45
46
  if (attempt < maxAttempts - 1) {
46
- Thread.sleep(1_000L)
47
+ Thread.sleep(backoffDelayMs(attempt))
47
48
  }
48
49
  }
49
50
 
@@ -61,10 +62,16 @@ internal class AndroidBackgroundHttpSync {
61
62
  locations: Array<StoredBackgroundLocation>
62
63
  ): Int {
63
64
  val connection = createConnection(sync)
64
- OutputStreamWriter(connection.outputStream).use { writer ->
65
- writer.write(sync.batchBody(locations).toString())
65
+ return try {
66
+ OutputStreamWriter(connection.outputStream).use { writer ->
67
+ writer.write(sync.batchBody(locations).toString())
68
+ }
69
+ val code = connection.responseCode
70
+ drainResponse(connection, code)
71
+ code
72
+ } finally {
73
+ connection.disconnect()
66
74
  }
67
- return connection.responseCode
68
75
  }
69
76
 
70
77
  private fun uploadSingleLocationsWithRetry(
@@ -93,7 +100,7 @@ internal class AndroidBackgroundHttpSync {
93
100
  lastError = error.message ?: "HTTP sync failed"
94
101
  }
95
102
  if (!didSync && attempt < maxAttempts - 1) {
96
- Thread.sleep(1_000L)
103
+ Thread.sleep(backoffDelayMs(attempt))
97
104
  }
98
105
  }
99
106
  if (!didSync) {
@@ -115,10 +122,16 @@ internal class AndroidBackgroundHttpSync {
115
122
  location: StoredBackgroundLocation
116
123
  ): Int {
117
124
  val connection = createConnection(sync)
118
- OutputStreamWriter(connection.outputStream).use { writer ->
119
- writer.write(sync.singleBody(location).toString())
125
+ return try {
126
+ OutputStreamWriter(connection.outputStream).use { writer ->
127
+ writer.write(sync.singleBody(location).toString())
128
+ }
129
+ val code = connection.responseCode
130
+ drainResponse(connection, code)
131
+ code
132
+ } finally {
133
+ connection.disconnect()
120
134
  }
121
- return connection.responseCode
122
135
  }
123
136
 
124
137
  private fun createConnection(sync: BackgroundHttpSyncOptions): HttpURLConnection {
@@ -131,4 +144,34 @@ internal class AndroidBackgroundHttpSync {
131
144
  sync.headers?.forEach { (key, value) -> setRequestProperty(key, value) }
132
145
  }
133
146
  }
147
+
148
+ // Drain (up to a cap) then disconnect so the socket is returned to the pool; an undrained
149
+ // HttpURLConnection blocks connection reuse and leaks sockets over a long trip.
150
+ private fun drainResponse(connection: HttpURLConnection, code: Int) {
151
+ runCatching {
152
+ val stream = if (code in 200..299) connection.inputStream else connection.errorStream
153
+ stream?.use { input ->
154
+ val buffer = ByteArray(4_096)
155
+ var total = 0
156
+ while (total < MAX_DRAIN_BYTES) {
157
+ val read = input.read(buffer)
158
+ if (read < 0) break
159
+ total += read
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // Exponential backoff with full jitter so many devices retrying a failed sync don't hammer the
166
+ // server in lockstep (thundering herd).
167
+ private fun backoffDelayMs(attempt: Int): Long {
168
+ return backoffBaseDelayMs(attempt, BASE_BACKOFF_MS, MAX_BACKOFF_MS) +
169
+ Random.nextLong(BASE_BACKOFF_MS)
170
+ }
171
+
172
+ private companion object {
173
+ const val BASE_BACKOFF_MS = 1_000L
174
+ const val MAX_BACKOFF_MS = 30_000L
175
+ const val MAX_DRAIN_BYTES = 64 * 1024
176
+ }
134
177
  }
@@ -0,0 +1,52 @@
1
+ package com.margelo.nitro.nitrogeolocation.background
2
+
3
+ import android.app.PendingIntent
4
+ import android.os.Build
5
+
6
+ /**
7
+ * Pure decision helpers for the background pipeline, extracted so they can be unit-tested with
8
+ * plain JUnit (no Android framework instances) — see BackgroundDecisionsTest. They take their
9
+ * inputs explicitly instead of reading global state. The Android constants used here
10
+ * (PendingIntent.FLAG_*, Build.VERSION_CODES.*) are compile-time `static final int` values, so the
11
+ * logic is fully evaluable on a plain JVM.
12
+ */
13
+
14
+ /**
15
+ * Flags for the broadcast PendingIntents the OS fills in at delivery time (location / geofence /
16
+ * activity). The result MUST be mutable on API 31+ (S), otherwise FusedLocationProviderClient
17
+ * rejects registration with "PendingIntent must be mutable" and zero updates are ever delivered.
18
+ * Pre-S PendingIntents are mutable by default, so no immutability flag is set there.
19
+ */
20
+ internal fun mutablePendingIntentFlags(sdkInt: Int): Int {
21
+ return if (sdkInt >= Build.VERSION_CODES.S) {
22
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
23
+ } else {
24
+ PendingIntent.FLAG_UPDATE_CURRENT
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Capped exponential backoff base (without jitter): baseMs * 2^attempt, clamped to [baseMs, maxMs].
30
+ * Callers add jitter on top to avoid synchronized retries across devices.
31
+ */
32
+ internal fun backoffBaseDelayMs(attempt: Int, baseMs: Long, maxMs: Long): Long {
33
+ if (attempt <= 0) return baseMs
34
+ val grown = if (attempt >= 31) maxMs else baseMs shl attempt
35
+ return grown.coerceIn(baseMs, maxMs)
36
+ }
37
+
38
+ /**
39
+ * Resolves the effective store cap (rows), preserving the library's original opt-out for unbounded
40
+ * storage while adding a safe default when the option is unset:
41
+ * - unset (null) → [default] (a safety cap so the store can't grow without bound by accident)
42
+ * - explicit <= 0 → 0, meaning UNBOUNDED — pruneRows treats <= 0 as no-prune, the same opt-out the
43
+ * original code gave for any non-positive value
44
+ * - explicit > 0 → that cap
45
+ */
46
+ internal fun resolveMaxStored(configured: Int?, default: Int): Int {
47
+ return when {
48
+ configured == null -> default
49
+ configured <= 0 -> 0
50
+ else -> configured
51
+ }
52
+ }
@@ -46,20 +46,26 @@ class NitroBackgroundEventHub {
46
46
  }
47
47
 
48
48
  fun emit(event: BackgroundEventEnvelope) {
49
- eventListeners.values.forEach { listener -> listener(event) }
49
+ eventListeners.values.forEach { listener -> dispatch { listener(event) } }
50
50
 
51
51
  when (event.type) {
52
52
  BackgroundEventType.LOCATION -> {
53
53
  event.location?.let { location ->
54
- locationListeners.values.forEach { listener -> listener(location) }
54
+ locationListeners.values.forEach { listener -> dispatch { listener(location) } }
55
55
  }
56
56
  }
57
57
  BackgroundEventType.ERROR -> {
58
58
  event.error?.let { error ->
59
- errorListeners.values.forEach { listener -> listener(error) }
59
+ errorListeners.values.forEach { listener -> dispatch { listener(error) } }
60
60
  }
61
61
  }
62
62
  else -> Unit
63
63
  }
64
64
  }
65
+
66
+ // Listeners run inline on the caller's thread (often the broadcast receiver thread). Isolate
67
+ // each one so a single throwing listener cannot abort delivery to the remaining listeners.
68
+ private inline fun dispatch(block: () -> Unit) {
69
+ runCatching(block).onFailure { NitroGeoLog.w("background event listener threw", it) }
70
+ }
65
71
  }
@@ -34,6 +34,14 @@ private const val ACTION_GEOFENCE_UPDATE =
34
34
  private const val ACTION_ACTIVITY_UPDATE =
35
35
  "com.margelo.nitro.nitrogeolocation.background.ACTIVITY_UPDATE"
36
36
 
37
+ // LocationError codes mirror the W3C GeolocationPositionError contract.
38
+ private const val ERROR_CODE_PERMISSION_DENIED = 1
39
+ private const val ERROR_CODE_POSITION_UNAVAILABLE = 2
40
+
41
+ // Default store caps so an unconfigured store cannot grow without bound over a long trip.
42
+ private const val DEFAULT_MAX_STORED_LOCATIONS = 10_000
43
+ private const val DEFAULT_MAX_STORED_EVENTS = 10_000
44
+
37
45
  class NitroBackgroundLocationController private constructor(
38
46
  private val context: Context
39
47
  ) {
@@ -59,12 +67,23 @@ class NitroBackgroundLocationController private constructor(
59
67
  private val httpSync = AndroidBackgroundHttpSync()
60
68
  private val taskExecutor = Executors.newSingleThreadExecutor()
61
69
 
70
+ // Serializes HTTP-sync uploads: a burst of locations queues onto one worker instead of
71
+ // spawning an unbounded number of raw threads.
72
+ private val syncExecutor = Executors.newSingleThreadExecutor()
73
+
62
74
  @Volatile
63
75
  private var config: BackgroundLocationOptions? = null
64
76
 
65
77
  @Volatile
66
78
  private var state = BackgroundLocationState.IDLE
67
79
 
80
+ @Volatile
81
+ private var lastError: LocationError? = null
82
+
83
+ // Serializes lifecycle transitions (configure/start/stop/reset), which are invoked from
84
+ // Nitro Promise.async worker threads and must not interleave on the shared singleton.
85
+ private val lifecycleLock = Any()
86
+
68
87
  fun checkBackgroundPermission(): BackgroundPermissionResult {
69
88
  return permissions.checkBackgroundPermission()
70
89
  }
@@ -78,9 +97,11 @@ class NitroBackgroundLocationController private constructor(
78
97
  }
79
98
 
80
99
  fun configure(options: BackgroundLocationOptions) {
81
- validate(options)
82
- config = options
83
- persistConfig(options)
100
+ synchronized(lifecycleLock) {
101
+ validate(options)
102
+ config = options
103
+ persistConfig(options)
104
+ }
84
105
  }
85
106
 
86
107
  fun getConfigOrNull(): BackgroundLocationOptions? {
@@ -94,41 +115,53 @@ class NitroBackgroundLocationController private constructor(
94
115
  }
95
116
 
96
117
  fun start(options: BackgroundLocationOptions?) {
97
- options?.let(::configure)
98
- val current = requireConfig()
99
- validate(current)
100
- if (permissions.foregroundPermission() != PermissionStatus.GRANTED) {
101
- throw SecurityException("Foreground location permission is required")
102
- }
103
- if (current.android?.foregroundService == null &&
104
- permissions.backgroundPermission() != BackgroundPermissionStatus.GRANTED) {
105
- throw SecurityException("Background location permission is required")
118
+ synchronized(lifecycleLock) {
119
+ options?.let(::configure)
120
+ val current = requireConfig()
121
+ validate(current)
122
+ NitroGeoLog.d(
123
+ "start(): provider=${current.android?.locationProvider} interval=${current.interval} state=$state"
124
+ )
125
+ if (permissions.foregroundPermission() != PermissionStatus.GRANTED) {
126
+ throw SecurityException("Foreground location permission is required")
127
+ }
128
+ if (current.android?.foregroundService == null &&
129
+ permissions.backgroundPermission() != BackgroundPermissionStatus.GRANTED) {
130
+ throw SecurityException("Background location permission is required")
131
+ }
132
+ state = BackgroundLocationState.STARTING
133
+ prefs.edit().putBoolean("running", true).apply()
134
+ ContextCompat.startForegroundService(
135
+ appContext,
136
+ Intent(appContext, NitroBackgroundLocationService::class.java)
137
+ )
138
+ // State stays STARTING until the service actually registers updates and the provider
139
+ // confirms (see startNativeLocationUpdates) — only then do we report RUNNING.
140
+ NitroGeoLog.d("start(): foreground service requested, state=STARTING")
106
141
  }
107
- state = BackgroundLocationState.STARTING
108
- prefs.edit().putBoolean("running", true).apply()
109
- ContextCompat.startForegroundService(
110
- appContext,
111
- Intent(appContext, NitroBackgroundLocationService::class.java)
112
- )
113
- state = BackgroundLocationState.RUNNING
114
142
  }
115
143
 
116
144
  fun stop() {
117
- state = BackgroundLocationState.STOPPING
118
- stopNativeLocationUpdates()
119
- stopActivityRecognition()
120
- appContext.stopService(Intent(appContext, NitroBackgroundLocationService::class.java))
121
- prefs.edit().putBoolean("running", false).apply()
122
- state = BackgroundLocationState.STOPPED
145
+ synchronized(lifecycleLock) {
146
+ NitroGeoLog.d("stop(): tearing down location updates")
147
+ state = BackgroundLocationState.STOPPING
148
+ stopNativeLocationUpdates()
149
+ stopActivityRecognition()
150
+ appContext.stopService(Intent(appContext, NitroBackgroundLocationService::class.java))
151
+ prefs.edit().putBoolean("running", false).apply()
152
+ state = BackgroundLocationState.STOPPED
153
+ }
123
154
  }
124
155
 
125
156
  fun reset() {
126
- stop()
127
- removeGeofences(null)
128
- config = null
129
- prefs.edit().clear().apply()
130
- store.clearEvents(null)
131
- store.clearLocations(null)
157
+ synchronized(lifecycleLock) {
158
+ stop()
159
+ removeGeofences(null)
160
+ config = null
161
+ prefs.edit().clear().apply()
162
+ store.clearEvents(null)
163
+ store.clearLocations(null)
164
+ }
132
165
  }
133
166
 
134
167
  fun getStatus(): BackgroundLocationStatus {
@@ -156,17 +189,65 @@ class NitroBackgroundLocationController private constructor(
156
189
  permissions.notificationPermission()
157
190
  ),
158
191
  null,
159
- null
192
+ currentLastError()
160
193
  )
161
194
  }
162
195
 
196
+ internal fun recordError(code: Int, message: String, throwable: Throwable? = null) {
197
+ val error = LocationError(code.toDouble(), message)
198
+ lastError = error
199
+ prefs.edit()
200
+ .putInt("lastErrorCode", code)
201
+ .putString("lastErrorMessage", message)
202
+ .putLong("lastErrorAt", System.currentTimeMillis())
203
+ .apply()
204
+ NitroGeoLog.e("background location error [$code]: $message", throwable)
205
+ runCatching {
206
+ dispatchEvent(
207
+ BackgroundEventEnvelope(
208
+ null,
209
+ null,
210
+ null,
211
+ null,
212
+ null,
213
+ error,
214
+ UUID.randomUUID().toString(),
215
+ BackgroundEventType.ERROR,
216
+ System.currentTimeMillis().toDouble(),
217
+ false
218
+ )
219
+ )
220
+ }
221
+ }
222
+
223
+ internal fun recordError(message: String, throwable: Throwable) =
224
+ recordError(ERROR_CODE_POSITION_UNAVAILABLE, message, throwable)
225
+
226
+ private fun clearError() {
227
+ lastError = null
228
+ prefs.edit()
229
+ .remove("lastErrorCode")
230
+ .remove("lastErrorMessage")
231
+ .remove("lastErrorAt")
232
+ .apply()
233
+ }
234
+
235
+ private fun currentLastError(): LocationError? {
236
+ lastError?.let { return it }
237
+ val message = prefs.getString("lastErrorMessage", null) ?: return null
238
+ return LocationError(prefs.getInt("lastErrorCode", 0).toDouble(), message)
239
+ .also { lastError = it }
240
+ }
241
+
163
242
  @SuppressLint("MissingPermission")
164
243
  fun startNativeLocationUpdates() {
165
244
  val current = requireConfig()
166
245
  if (current.android?.locationProvider == AndroidBackgroundProvider.ANDROID_PLATFORM) {
246
+ NitroGeoLog.d("startNativeLocationUpdates(): ANDROID_PLATFORM LocationManager path")
167
247
  startPlatformLocationUpdates(current)
168
248
  return
169
249
  }
250
+ NitroGeoLog.d("startNativeLocationUpdates(): FUSED provider, registering broadcast PendingIntent")
170
251
  val request = LocationRequest.Builder(
171
252
  resolvePriority(current),
172
253
  current.interval?.toLong() ?: 10_000L
@@ -177,7 +258,27 @@ class NitroBackgroundLocationController private constructor(
177
258
  .setMaxUpdateDelayMillis(current.maxUpdateDelay?.toLong() ?: 0L)
178
259
  .build()
179
260
 
180
- fusedLocationClient.requestLocationUpdates(request, locationPendingIntent())
261
+ try {
262
+ fusedLocationClient.requestLocationUpdates(request, locationPendingIntent())
263
+ .addOnSuccessListener {
264
+ NitroGeoLog.d("startNativeLocationUpdates(): fused registration accepted")
265
+ state = BackgroundLocationState.RUNNING
266
+ clearError()
267
+ }
268
+ .addOnFailureListener { error ->
269
+ recordError(
270
+ ERROR_CODE_POSITION_UNAVAILABLE,
271
+ "Failed to register fused location updates: ${error.message}",
272
+ error
273
+ )
274
+ }
275
+ } catch (error: SecurityException) {
276
+ recordError(
277
+ ERROR_CODE_PERMISSION_DENIED,
278
+ "Missing location permission for fused updates: ${error.message}",
279
+ error
280
+ )
281
+ }
181
282
  }
182
283
 
183
284
  fun stopNativeLocationUpdates() {
@@ -186,6 +287,7 @@ class NitroBackgroundLocationController private constructor(
186
287
  }
187
288
 
188
289
  fun handleNativeLocation(location: Location, source: BackgroundLocationSource) {
290
+ NitroGeoLog.d("handleNativeLocation(): src=$source lat=${location.latitude} lng=${location.longitude}")
189
291
  val id = UUID.randomUUID().toString()
190
292
  val backgroundLocation = BackgroundLocation(
191
293
  id,
@@ -391,7 +493,7 @@ class NitroBackgroundLocationController private constructor(
391
493
  if (interval > 0 && now - lastSyncAt < interval) return
392
494
  prefs.edit().putLong("lastSyncAt", now).apply()
393
495
 
394
- Thread {
496
+ syncExecutor.execute {
395
497
  val result = runCatching { syncStoredLocations() }.getOrElse { error ->
396
498
  BackgroundHttpSyncResult(
397
499
  false,
@@ -415,7 +517,7 @@ class NitroBackgroundLocationController private constructor(
415
517
  )
416
518
  persistEventIfNeeded(event)
417
519
  dispatchEvent(event)
418
- }.start()
520
+ }
419
521
  }
420
522
 
421
523
  private fun shouldPersist(): Boolean {
@@ -428,12 +530,18 @@ class NitroBackgroundLocationController private constructor(
428
530
  store.pruneEvents(currentMaxStoredEvents())
429
531
  }
430
532
 
431
- private fun currentMaxStoredLocations(): Int? {
432
- return getConfigOrNull()?.maxStoredLocations?.toInt()?.takeIf { it > 0 }
533
+ private fun currentMaxStoredLocations(): Int {
534
+ return resolveMaxStored(
535
+ getConfigOrNull()?.maxStoredLocations?.toInt(),
536
+ DEFAULT_MAX_STORED_LOCATIONS
537
+ )
433
538
  }
434
539
 
435
- private fun currentMaxStoredEvents(): Int? {
436
- return getConfigOrNull()?.maxStoredEvents?.toInt()?.takeIf { it > 0 }
540
+ private fun currentMaxStoredEvents(): Int {
541
+ return resolveMaxStored(
542
+ getConfigOrNull()?.maxStoredEvents?.toInt(),
543
+ DEFAULT_MAX_STORED_EVENTS
544
+ )
437
545
  }
438
546
 
439
547
  @SuppressLint("MissingPermission")
@@ -443,13 +551,26 @@ class NitroBackgroundLocationController private constructor(
443
551
  val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
444
552
  .filter { provider -> runCatching { platformLocationManager.isProviderEnabled(provider) }.getOrDefault(false) }
445
553
  .ifEmpty { listOf(LocationManager.GPS_PROVIDER) }
554
+ var registered = false
446
555
  providers.forEach { provider ->
447
- platformLocationManager.requestLocationUpdates(
448
- provider,
449
- interval,
450
- distance,
451
- locationPendingIntent()
452
- )
556
+ try {
557
+ platformLocationManager.requestLocationUpdates(
558
+ provider,
559
+ interval,
560
+ distance,
561
+ locationPendingIntent()
562
+ )
563
+ registered = true
564
+ } catch (error: SecurityException) {
565
+ recordError(
566
+ ERROR_CODE_PERMISSION_DENIED,
567
+ "Missing location permission for $provider updates: ${error.message}",
568
+ error
569
+ )
570
+ }
571
+ }
572
+ if (registered) {
573
+ state = BackgroundLocationState.RUNNING
453
574
  }
454
575
  }
455
576
 
@@ -484,10 +605,16 @@ class NitroBackgroundLocationController private constructor(
484
605
 
485
606
  private fun dispatchEvent(event: BackgroundEventEnvelope) {
486
607
  eventHub.emit(event)
487
- val intent = Intent(appContext, NitroBackgroundHeadlessTaskService::class.java)
488
- .putExtra("event", event.toJson().toString())
489
- appContext.startService(intent)
490
- HeadlessJsTaskService.acquireWakeLockNow(appContext)
608
+ // The in-process eventHub already delivered to any live JS listeners; the headless task is
609
+ // the fallback for when JS is dead. Guard its start: startService from the background can be
610
+ // rejected (ForegroundServiceStartNotAllowed / IllegalState), which must not crash the
611
+ // broadcast receiver thread that drives delivery.
612
+ runCatching {
613
+ val intent = Intent(appContext, NitroBackgroundHeadlessTaskService::class.java)
614
+ .putExtra("event", event.toJson().toString())
615
+ appContext.startService(intent)
616
+ HeadlessJsTaskService.acquireWakeLockNow(appContext)
617
+ }.onFailure { NitroGeoLog.w("dispatchEvent: headless task dispatch failed", it) }
491
618
  }
492
619
 
493
620
  private fun waitForTask(task: Task<Void>) {
@@ -661,7 +788,7 @@ class NitroBackgroundLocationController private constructor(
661
788
  appContext,
662
789
  1001,
663
790
  intent,
664
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
791
+ mutablePendingIntentFlags(Build.VERSION.SDK_INT)
665
792
  )
666
793
  }
667
794
 
@@ -672,7 +799,7 @@ class NitroBackgroundLocationController private constructor(
672
799
  appContext,
673
800
  1002,
674
801
  intent,
675
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
802
+ mutablePendingIntentFlags(Build.VERSION.SDK_INT)
676
803
  )
677
804
  }
678
805
 
@@ -683,7 +810,7 @@ class NitroBackgroundLocationController private constructor(
683
810
  appContext,
684
811
  1003,
685
812
  intent,
686
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
813
+ mutablePendingIntentFlags(Build.VERSION.SDK_INT)
687
814
  )
688
815
  }
689
816
 
@@ -2,7 +2,9 @@ package com.margelo.nitro.nitrogeolocation.background
2
2
 
3
3
  import android.app.Service
4
4
  import android.content.Intent
5
+ import android.content.pm.ServiceInfo
5
6
  import android.os.IBinder
7
+ import androidx.core.app.ServiceCompat
6
8
 
7
9
  class NitroBackgroundLocationService : Service() {
8
10
  private val controller by lazy {
@@ -14,18 +16,38 @@ class NitroBackgroundLocationService : Service() {
14
16
  val foregroundService = config.android?.foregroundService
15
17
  ?: throw IllegalStateException("Android foreground service options are required")
16
18
  val notification = NitroBackgroundNotificationFactory.create(this, foregroundService)
17
- startForeground(
18
- foregroundService.notificationId?.toInt() ?: 9471,
19
- notification
20
- )
19
+ val notificationId = foregroundService.notificationId?.toInt() ?: 9471
20
+ NitroGeoLog.d("Service.onStartCommand(): startForeground id=$notificationId type=location")
21
+ try {
22
+ ServiceCompat.startForeground(
23
+ this,
24
+ notificationId,
25
+ notification,
26
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
27
+ )
28
+ } catch (error: Exception) {
29
+ // Android 14+ rejects a "location" foreground service if location permission is not
30
+ // held at start time (SecurityException), and Android 12+ rejects background starts
31
+ // (ForegroundServiceStartNotAllowedException, an IllegalStateException). Record and stop
32
+ // cleanly instead of letting the service crash.
33
+ controller.recordError("Failed to start foreground location service: ${error.message}", error)
34
+ stopSelf()
35
+ return START_NOT_STICKY
36
+ }
37
+ NitroGeoLog.d("Service.onStartCommand(): starting native location updates")
21
38
  controller.startNativeLocationUpdates()
22
39
  if (config.trackingMode == com.margelo.nitro.nitrogeolocation.BackgroundTrackingMode.ACTIVITYAWARE ||
23
40
  config.activityRecognition?.enabled == true) {
24
41
  runCatching {
25
42
  controller.startActivityRecognition(config.activityRecognition)
43
+ }.onFailure {
44
+ controller.recordError("Failed to start activity recognition: ${it.message}", it)
26
45
  }
27
46
  }
28
47
  runCatching { controller.registerPersistedGeofencesIfNeeded() }
48
+ .onFailure {
49
+ controller.recordError("Failed to register persisted geofences: ${it.message}", it)
50
+ }
29
51
  return if (config.stopOnTerminate == false) START_STICKY else START_NOT_STICKY
30
52
  }
31
53
 
@@ -29,6 +29,12 @@ import org.json.JSONObject
29
29
  class NitroBackgroundStore(context: Context) :
30
30
  SQLiteOpenHelper(context, "nitro_background_location.db", null, 3) {
31
31
 
32
+ init {
33
+ // WAL lets the broadcast-receiver writer and concurrent readers (JS Promise threads and the
34
+ // sync worker) proceed without blocking each other under a steady stream of location inserts.
35
+ setWriteAheadLoggingEnabled(true)
36
+ }
37
+
32
38
  override fun onCreate(db: SQLiteDatabase) {
33
39
  db.execSQL(
34
40
  """
@@ -13,14 +13,25 @@ class NitroBootReceiver : BroadcastReceiver() {
13
13
  return
14
14
  }
15
15
 
16
- val prefs = context.applicationContext.getSharedPreferences(
17
- "nitro_background_location",
18
- Context.MODE_PRIVATE
19
- )
20
- val controller = NitroBackgroundLocationController.getInstance(context)
21
- runCatching { controller.registerPersistedGeofencesIfNeeded() }
22
- if (prefs.getBoolean("startOnBoot", false)) {
23
- runCatching { controller.start(null) }
24
- }
16
+ // registerPersistedGeofencesIfNeeded() blocks (waitForTask, up to 30s) and start() does
17
+ // disk I/O; running them inline would block the broadcast's main thread and risk an ANR.
18
+ // Hand off to a worker thread and keep the broadcast alive with goAsync() until it finishes.
19
+ val appContext = context.applicationContext
20
+ val pendingResult = goAsync()
21
+ Thread {
22
+ try {
23
+ val prefs = appContext.getSharedPreferences(
24
+ "nitro_background_location",
25
+ Context.MODE_PRIVATE
26
+ )
27
+ val controller = NitroBackgroundLocationController.getInstance(appContext)
28
+ runCatching { controller.registerPersistedGeofencesIfNeeded() }
29
+ if (prefs.getBoolean("startOnBoot", false)) {
30
+ runCatching { controller.start(null) }
31
+ }
32
+ } finally {
33
+ pendingResult.finish()
34
+ }
35
+ }.start()
25
36
  }
26
37
  }
@@ -0,0 +1,33 @@
1
+ package com.margelo.nitro.nitrogeolocation.background
2
+
3
+ import android.util.Log
4
+ import com.margelo.nitro.nitrogeolocation.BuildConfig
5
+
6
+ /**
7
+ * Lightweight tagged logger for the background location pipeline.
8
+ *
9
+ * The background delivery path runs across a foreground service, a broadcast receiver and a
10
+ * headless task, where a misconfiguration can silently stop location delivery with no error.
11
+ * This logger makes that path observable from logcat.
12
+ *
13
+ * Verbose [d] logs are gated behind [verbose] (defaults to debug builds) so release builds stay
14
+ * quiet, while [w] and [e] always emit because they mark real delivery or registration failures.
15
+ */
16
+ internal object NitroGeoLog {
17
+ private const val TAG = "NitroGeolocation"
18
+
19
+ @Volatile
20
+ var verbose: Boolean = BuildConfig.DEBUG
21
+
22
+ fun d(message: String) {
23
+ if (verbose) Log.d(TAG, message)
24
+ }
25
+
26
+ fun w(message: String, error: Throwable? = null) {
27
+ Log.w(TAG, message, error)
28
+ }
29
+
30
+ fun e(message: String, error: Throwable? = null) {
31
+ Log.e(TAG, message, error)
32
+ }
33
+ }
@@ -11,6 +11,7 @@ import com.margelo.nitro.nitrogeolocation.BackgroundLocationSource
11
11
 
12
12
  class NitroLocationUpdateReceiver : BroadcastReceiver() {
13
13
  override fun onReceive(context: Context, intent: Intent) {
14
+ NitroGeoLog.d("LocationUpdateReceiver.onReceive(): action=${intent.action}")
14
15
  val controller = NitroBackgroundLocationController.getInstance(context)
15
16
  val platformLocation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
16
17
  intent.getParcelableExtra(LocationManager.KEY_LOCATION_CHANGED, Location::class.java)
@@ -26,7 +27,16 @@ class NitroLocationUpdateReceiver : BroadcastReceiver() {
26
27
  return
27
28
  }
28
29
 
29
- val result = LocationResult.extractResult(intent) ?: return
30
+ val result = LocationResult.extractResult(intent)
31
+ if (result == null) {
32
+ NitroGeoLog.w(
33
+ "LocationUpdateReceiver.onReceive(): broadcast carried no location " +
34
+ "(KEY_LOCATION_CHANGED and LocationResult both null) — dropping. " +
35
+ "On Android 12+ this usually means the broadcast PendingIntent was built " +
36
+ "with FLAG_IMMUTABLE, so the OS could not inject the LocationResult extras."
37
+ )
38
+ return
39
+ }
30
40
  for (location in result.locations) {
31
41
  controller.handleNativeLocation(
32
42
  location,
@@ -0,0 +1,67 @@
1
+ package com.margelo.nitro.nitrogeolocation.background
2
+
3
+ import android.app.PendingIntent
4
+ import android.os.Build
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertTrue
8
+ import org.junit.Test
9
+
10
+ class BackgroundDecisionsTest {
11
+ @Test
12
+ fun pendingIntentFlagsAreMutableOnApiSAndAbove() {
13
+ val flags = mutablePendingIntentFlags(Build.VERSION_CODES.S)
14
+ assertTrue(
15
+ "FLAG_MUTABLE must be set on API >= S, or the OS rejects the registration",
16
+ (flags and PendingIntent.FLAG_MUTABLE) != 0
17
+ )
18
+ assertTrue(
19
+ "FLAG_UPDATE_CURRENT must be set",
20
+ (flags and PendingIntent.FLAG_UPDATE_CURRENT) != 0
21
+ )
22
+ }
23
+
24
+ @Test
25
+ fun pendingIntentFlagsAreNotImmutableBelowApiS() {
26
+ val flags = mutablePendingIntentFlags(Build.VERSION_CODES.R)
27
+ assertFalse(
28
+ "FLAG_IMMUTABLE must not be set below S (PendingIntents are mutable by default there)",
29
+ (flags and PendingIntent.FLAG_IMMUTABLE) != 0
30
+ )
31
+ assertTrue(
32
+ "FLAG_UPDATE_CURRENT must be set",
33
+ (flags and PendingIntent.FLAG_UPDATE_CURRENT) != 0
34
+ )
35
+ }
36
+
37
+ @Test
38
+ fun backoffGrowsExponentiallyThenCaps() {
39
+ assertEquals(1_000L, backoffBaseDelayMs(0, 1_000L, 30_000L))
40
+ assertEquals(2_000L, backoffBaseDelayMs(1, 1_000L, 30_000L))
41
+ assertEquals(4_000L, backoffBaseDelayMs(2, 1_000L, 30_000L))
42
+ assertEquals(8_000L, backoffBaseDelayMs(3, 1_000L, 30_000L))
43
+ assertEquals(30_000L, backoffBaseDelayMs(10, 1_000L, 30_000L))
44
+ }
45
+
46
+ @Test
47
+ fun backoffClampsNegativeAttemptToBase() {
48
+ assertEquals(1_000L, backoffBaseDelayMs(-1, 1_000L, 30_000L))
49
+ }
50
+
51
+ @Test
52
+ fun resolveMaxStoredUsesConfiguredPositiveValue() {
53
+ assertEquals(500, resolveMaxStored(500, 10_000))
54
+ }
55
+
56
+ @Test
57
+ fun resolveMaxStoredFallsBackToDefaultWhenUnset() {
58
+ assertEquals(10_000, resolveMaxStored(null, 10_000))
59
+ }
60
+
61
+ @Test
62
+ fun resolveMaxStoredTreatsNonPositiveAsUnbounded() {
63
+ // 0 is the library's explicit unbounded opt-out (pruneRows treats <= 0 as no-prune).
64
+ assertEquals(0, resolveMaxStored(0, 10_000))
65
+ assertEquals(0, resolveMaxStored(-5, 10_000))
66
+ }
67
+ }
@@ -23,6 +23,7 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
23
23
  private var delegate: NitroBackgroundLocationDelegate?
24
24
  private let motionManager = CMMotionActivityManager()
25
25
  private let motionQueue = OperationQueue()
26
+ private var isMotionUpdatesRunning = false
26
27
  private let syncQueue = DispatchQueue(label: "nitro.background.sync")
27
28
  private let httpSync = IOSBackgroundHttpSync()
28
29
  private var permissionSemaphore: DispatchSemaphore?
@@ -114,7 +115,7 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
114
115
  self.manager?.stopUpdatingLocation()
115
116
  self.manager?.stopMonitoringSignificantLocationChanges()
116
117
  }
117
- self.motionManager.stopActivityUpdates()
118
+ self.stopMotionUpdatesIfRunning()
118
119
  self.isRunning = false
119
120
  self.state = .stopped
120
121
  }
@@ -128,7 +129,7 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
128
129
  self.manager?.stopMonitoringSignificantLocationChanges()
129
130
  self.manager?.monitoredRegions.forEach { self.manager?.stopMonitoring(for: $0) }
130
131
  }
131
- self.motionManager.stopActivityUpdates()
132
+ self.stopMotionUpdatesIfRunning()
132
133
  self.options = nil
133
134
  self.defaults.removeObject(forKey: self.optionsKey)
134
135
  self.isRunning = false
@@ -376,7 +377,7 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
376
377
 
377
378
  func stopActivityRecognition() throws -> Promise<Void> {
378
379
  return Promise.async {
379
- self.motionManager.stopActivityUpdates()
380
+ self.stopMotionUpdatesIfRunning()
380
381
  }
381
382
  }
382
383
 
@@ -496,6 +497,13 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
496
497
  guard let self, let activity else { return }
497
498
  self.handleMotionActivity(activity)
498
499
  }
500
+ isMotionUpdatesRunning = true
501
+ }
502
+
503
+ private func stopMotionUpdatesIfRunning() {
504
+ guard isMotionUpdatesRunning else { return }
505
+ motionManager.stopActivityUpdates()
506
+ isMotionUpdatesRunning = false
499
507
  }
500
508
 
501
509
  private func handleMotionActivity(_ activity: CMMotionActivity) {
@@ -536,6 +544,12 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
536
544
  }
537
545
 
538
546
  func handleError(_ error: Error) {
547
+ // kCLErrorLocationUnknown is transient — CoreLocation couldn't get a fix right now but keeps
548
+ // trying (very common on the Simulator and during brief GPS gaps). Apple's guidance is to
549
+ // ignore it; forwarding it would pollute the consumer's error stream with benign noise.
550
+ if let clError = error as? CLError, clError.code == .locationUnknown {
551
+ return
552
+ }
539
553
  let locationError = LocationError(code: -1, message: error.localizedDescription)
540
554
  errorListeners.values.forEach { $0(locationError) }
541
555
  }
@@ -682,25 +696,31 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
682
696
  return max(Int(value.rounded(.down)), 1)
683
697
  }
684
698
 
699
+ // Default store cap (rows) applied when maxStored* is unset, matching the Android side. An
700
+ // explicit value <= 0 means UNBOUNDED (no cap), preserving the library's original opt-out.
701
+ private static let defaultMaxStoredRows = 10_000
702
+
703
+ private func resolveMaxStored(_ configured: Double?, default def: Int) -> Int? {
704
+ guard let configured = configured else { return def }
705
+ if configured <= 0 { return nil }
706
+ return Int(configured)
707
+ }
708
+
685
709
  private func appendStoredLocation(_ location: StoredBackgroundLocation) {
686
710
  guard shouldPersist() else { return }
687
711
  storedLocations.append(location)
688
- if let maxValue = options?.maxStoredLocations, maxValue > 0 {
689
- let max = Int(maxValue)
690
- if storedLocations.count > max {
691
- storedLocations = Array(storedLocations.suffix(max))
692
- }
712
+ if let max = resolveMaxStored(options?.maxStoredLocations, default: Self.defaultMaxStoredRows),
713
+ storedLocations.count > max {
714
+ storedLocations = Array(storedLocations.suffix(max))
693
715
  }
694
716
  }
695
717
 
696
718
  private func appendStoredEvent(_ event: StoredBackgroundEventEnvelope) {
697
719
  guard shouldPersist() else { return }
698
720
  storedEvents.append(event)
699
- if let maxValue = options?.maxStoredEvents, maxValue > 0 {
700
- let max = Int(maxValue)
701
- if storedEvents.count > max {
702
- storedEvents = Array(storedEvents.suffix(max))
703
- }
721
+ if let max = resolveMaxStored(options?.maxStoredEvents, default: Self.defaultMaxStoredRows),
722
+ storedEvents.count > max {
723
+ storedEvents = Array(storedEvents.suffix(max))
704
724
  }
705
725
  }
706
726
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-geolocation",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Nitro-powered native geolocation for modern React Native apps",
5
5
  "main": "src/index",
6
6
  "source": "src/index",
@@ -76,7 +76,15 @@ export interface BackgroundLocationOptions {
76
76
  maxUpdateDelay?: number;
77
77
  waitForAccurateLocation?: boolean;
78
78
  persist?: boolean;
79
+ /**
80
+ * Max number of locations retained in the on-device store before older rows are pruned.
81
+ * Unset → a built-in safety cap (the native default). Set to `0` for UNBOUNDED storage.
82
+ */
79
83
  maxStoredLocations?: number;
84
+ /**
85
+ * Max number of events retained in the on-device store before older rows are pruned.
86
+ * Unset → a built-in safety cap (the native default). Set to `0` for UNBOUNDED storage.
87
+ */
80
88
  maxStoredEvents?: number;
81
89
  stopOnTerminate?: boolean;
82
90
  startOnBoot?: boolean;