react-native-nitro-location-tracking 0.1.12 → 0.1.14

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.
Files changed (37) hide show
  1. package/README.md +52 -0
  2. package/android/CMakeLists.txt +4 -1
  3. package/android/src/main/java/com/margelo/nitro/nitrolocationtracking/AirplaneModeMonitor.kt +72 -0
  4. package/android/src/main/java/com/margelo/nitro/nitrolocationtracking/LocationEngine.kt +30 -9
  5. package/android/src/main/java/com/margelo/nitro/nitrolocationtracking/NitroLocationTracking.kt +173 -1
  6. package/android/src/main/java/com/margelo/nitro/nitrolocationtracking/PermissionStatusMonitor.kt +26 -0
  7. package/cpp/HybridNitroLocationComplexLogicsCalculation.cpp +204 -0
  8. package/cpp/HybridNitroLocationComplexLogicsCalculation.hpp +29 -0
  9. package/ios/LocationEngine.swift +11 -0
  10. package/ios/NitroLocationTracking.swift +68 -0
  11. package/lib/module/NitroLocationComplexLogicsCalculation.nitro.js +4 -0
  12. package/lib/module/NitroLocationComplexLogicsCalculation.nitro.js.map +1 -0
  13. package/lib/module/index.js +1 -2
  14. package/lib/module/index.js.map +1 -1
  15. package/lib/typescript/src/NitroLocationComplexLogicsCalculation.nitro.d.ts +25 -0
  16. package/lib/typescript/src/NitroLocationComplexLogicsCalculation.nitro.d.ts.map +1 -0
  17. package/lib/typescript/src/NitroLocationTracking.nitro.d.ts +17 -0
  18. package/lib/typescript/src/NitroLocationTracking.nitro.d.ts.map +1 -1
  19. package/lib/typescript/src/index.d.ts +2 -0
  20. package/lib/typescript/src/index.d.ts.map +1 -1
  21. package/nitrogen/generated/android/c++/JHybridNitroLocationTrackingSpec.cpp +25 -0
  22. package/nitrogen/generated/android/c++/JHybridNitroLocationTrackingSpec.hpp +3 -0
  23. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrolocationtracking/HybridNitroLocationTrackingSpec.kt +17 -0
  24. package/nitrogen/generated/android/nitrolocationtracking+autolinking.cmake +1 -0
  25. package/nitrogen/generated/ios/c++/HybridNitroLocationTrackingSpecSwift.hpp +22 -0
  26. package/nitrogen/generated/ios/swift/HybridNitroLocationTrackingSpec.swift +3 -0
  27. package/nitrogen/generated/ios/swift/HybridNitroLocationTrackingSpec_cxx.swift +47 -0
  28. package/nitrogen/generated/shared/c++/HybridNitroLocationComplexLogicsCalculationSpec.cpp +25 -0
  29. package/nitrogen/generated/shared/c++/HybridNitroLocationComplexLogicsCalculationSpec.hpp +72 -0
  30. package/nitrogen/generated/shared/c++/HybridNitroLocationTrackingSpec.cpp +3 -0
  31. package/nitrogen/generated/shared/c++/HybridNitroLocationTrackingSpec.hpp +3 -0
  32. package/nitrogen/generated/shared/c++/LocationPoint.hpp +99 -0
  33. package/nitrogen/generated/shared/c++/TripMathStats.hpp +95 -0
  34. package/package.json +1 -1
  35. package/src/NitroLocationComplexLogicsCalculation.nitro.ts +37 -0
  36. package/src/NitroLocationTracking.nitro.ts +19 -0
  37. package/src/index.tsx +10 -1
package/README.md CHANGED
@@ -453,6 +453,36 @@ NitroLocationModule.onProviderStatusChange((gps, network) => {
453
453
  });
454
454
  ```
455
455
 
456
+ ### Prompt user to enable GPS
457
+
458
+ Ask the user to turn on device location. On Android this shows the native
459
+ Google Play Services in-app dialog ("For better experience, turn on device
460
+ location…") without leaving the app. On iOS there is no equivalent system
461
+ dialog, so this opens your app's Settings page and resolves after the user
462
+ returns to the app.
463
+
464
+ ```tsx
465
+ import NitroLocationModule from 'react-native-nitro-location-tracking';
466
+
467
+ async function ensureGpsOn() {
468
+ if (NitroLocationModule.isLocationServicesEnabled()) {
469
+ return true;
470
+ }
471
+ const enabled = await NitroLocationModule.openLocationSettings();
472
+ if (enabled) {
473
+ NitroLocationModule.startTracking();
474
+ } else {
475
+ // User declined the dialog (Android) or did not enable GPS (iOS)
476
+ }
477
+ return enabled;
478
+ }
479
+ ```
480
+
481
+ **Platform behavior:**
482
+
483
+ - **Android** — Uses `SettingsClient.checkLocationSettings()` + `startResolutionForResult`. Resolves `true` if GPS is already on or if the user accepts the dialog, `false` if the user declines or the dialog cannot be shown.
484
+ - **iOS** — Opens the app's Settings page via `UIApplication.openSettingsURLString` and listens for `UIApplication.didBecomeActiveNotification` to detect the return to foreground. Resolves `true` if `CLLocationManager.locationServicesEnabled()` is on after the user returns, `false` otherwise.
485
+
456
486
  ### Permission Status
457
487
 
458
488
  Check the current location permission status without prompting the user:
@@ -704,7 +734,10 @@ type PermissionStatus =
704
734
  | `getTripStats()` | `TripStats` | Get current trip stats without stopping |
705
735
  | `resetTripCalculation()` | `void` | Reset trip calculator |
706
736
  | `isLocationServicesEnabled()` | `boolean` | Check if GPS/location is enabled on device |
737
+ | `openLocationSettings()` | `Promise<boolean>` | Prompt user to enable GPS. Resolves `true` if enabled, `false` if not |
707
738
  | `onProviderStatusChange(callback)` | `void` | Register GPS/network provider status callback |
739
+ | `isAirplaneModeEnabled()` | `boolean` | Check if Airplane mode is active on Android |
740
+ | `onAirplaneModeChange(callback)` | `void` | Register Airplane mode state-transition callback |
708
741
  | `getLocationPermissionStatus()` | `PermissionStatus` | Check current location permission without prompting |
709
742
  | `requestLocationPermission()` | `Promise<PermissionStatus>` | Request location permission and return the resulting status |
710
743
  | `onPermissionStatusChange(callback)` | `void` | Register a callback that fires when location permission status changes |
@@ -721,6 +754,25 @@ type PermissionStatus =
721
754
  | `shortestRotation(from, to)` | Calculate shortest rotation path to avoid spinning |
722
755
  | `requestLocationPermission()` | Request location + notification permissions (Android) |
723
756
 
757
+ ### Pure C++ Math Engine
758
+
759
+ For computationally heavy tasks like array slicing and trip mapping, use the pure C++ Math Engine to bypass the JS thread entirely.
760
+
761
+ ```typescript
762
+ import { NitroLocationCalculations } from 'react-native-nitro-location-tracking';
763
+
764
+ // Instantly compute heavy math directly in C++
765
+ const stats = NitroLocationCalculations.calculateTotalTripStats(points);
766
+ ```
767
+
768
+ | Method | Returns | Description |
769
+ | :--- | :--- | :--- |
770
+ | `calculateTotalTripStats(points)` | `TripMathStats` | Instantly computes exact Haversine distance, time, and max/average speed over an array of thousands of points. |
771
+ | `filterAnomalousPoints(points, maxSpeedMps)` | `LocationPoint[]` | Cleans an array of points by mathematically stripping out teleportation jumps that exceed the given speed limit. |
772
+ | `smoothPath(points, toleranceMeters)` | `LocationPoint[]` | Simplifies a highly dense GPS path into perfect drawing lines using the Ramer-Douglas-Peucker algorithm. |
773
+ | `calculateBearing(lat1, lon1, lat2, lon2)` | `number` | Lightning fast C++ trigonometric bearing computation for raw coordinates. |
774
+ | `encodeGeohash(lat, lon, precision)` | `string` | Converts coordinates into a Geohash string instantly representing geological boundaries. |
775
+
724
776
  ## Publishing to npm
725
777
 
726
778
  ### Prerequisites
@@ -6,7 +6,10 @@ set(CMAKE_VERBOSE_MAKEFILE ON)
6
6
  set(CMAKE_CXX_STANDARD 20)
7
7
 
8
8
  # Define C++ library and add all sources
9
- add_library(${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp)
9
+ add_library(${PACKAGE_NAME} SHARED
10
+ src/main/cpp/cpp-adapter.cpp
11
+ ../cpp/HybridNitroLocationComplexLogicsCalculation.cpp
12
+ )
10
13
 
11
14
  # Add Nitrogen specs :)
12
15
  include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/nitrolocationtracking+autolinking.cmake)
@@ -0,0 +1,72 @@
1
+ package com.margelo.nitro.nitrolocationtracking
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.IntentFilter
7
+ import android.os.Build
8
+ import android.provider.Settings
9
+ import android.util.Log
10
+
11
+ class AirplaneModeMonitor(private val context: Context) {
12
+
13
+ companion object {
14
+ private const val TAG = "AirplaneModeMonitor"
15
+ }
16
+
17
+ private var callback: ((Boolean) -> Unit)? = null
18
+ private var lastState: Boolean? = null
19
+ private var receiver: BroadcastReceiver? = null
20
+
21
+ fun setCallback(callback: (Boolean) -> Unit) {
22
+ this.callback = callback
23
+
24
+ // Emit current state immediately
25
+ val current = isAirplaneModeEnabled()
26
+ lastState = current
27
+ callback.invoke(current)
28
+
29
+ registerReceiver()
30
+ }
31
+
32
+ fun isAirplaneModeEnabled(): Boolean {
33
+ return Settings.Global.getInt(
34
+ context.contentResolver,
35
+ Settings.Global.AIRPLANE_MODE_ON, 0
36
+ ) != 0
37
+ }
38
+
39
+ private fun registerReceiver() {
40
+ if (receiver != null) return
41
+
42
+ receiver = object : BroadcastReceiver() {
43
+ override fun onReceive(context: Context?, intent: Intent?) {
44
+ if (intent?.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
45
+ val isAirplaneModeOn = intent.getBooleanExtra("state", false)
46
+ if (isAirplaneModeOn != lastState) {
47
+ lastState = isAirplaneModeOn
48
+ Log.d(TAG, "Airplane mode changed: $isAirplaneModeOn")
49
+ callback?.invoke(isAirplaneModeOn)
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ val filter = IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED)
56
+ context.registerReceiver(receiver, filter)
57
+ Log.d(TAG, "Airplane mode receiver registered")
58
+ }
59
+
60
+ fun destroy() {
61
+ receiver?.let {
62
+ try {
63
+ context.unregisterReceiver(it)
64
+ } catch (e: Exception) {
65
+ Log.w(TAG, "Error unregistering receiver: ${e.message}")
66
+ }
67
+ }
68
+ receiver = null
69
+ callback = null
70
+ lastState = null
71
+ }
72
+ }
@@ -34,7 +34,7 @@ class LocationEngine(private val context: Context) {
34
34
  val isTracking: Boolean get() = tracking
35
35
 
36
36
  @SuppressLint("MissingPermission")
37
- fun start(config: LocationConfig) {
37
+ fun start(config: LocationConfig): Boolean {
38
38
  if (tracking) {
39
39
  locationCallback?.let { fusedClient.removeLocationUpdates(it) }
40
40
  }
@@ -59,9 +59,25 @@ class LocationEngine(private val context: Context) {
59
59
  Log.d(TAG, "onLocationAvailability — isLocationAvailable=${availability.isLocationAvailable}")
60
60
  }
61
61
  }
62
- fusedClient.requestLocationUpdates(
63
- request, locationCallback!!, Looper.getMainLooper())
64
- tracking = true
62
+ return try {
63
+ fusedClient.requestLocationUpdates(
64
+ request, locationCallback!!, Looper.getMainLooper())
65
+ tracking = true
66
+ true
67
+ } catch (e: SecurityException) {
68
+ // Permission was revoked between our caller's check and this call, or
69
+ // the FusedLocationProvider is otherwise refusing access. Fail closed
70
+ // instead of crashing the process.
71
+ Log.w(TAG, "requestLocationUpdates refused — permission missing: ${e.message}")
72
+ locationCallback = null
73
+ tracking = false
74
+ false
75
+ } catch (e: Exception) {
76
+ Log.e(TAG, "requestLocationUpdates failed: ${e.message}")
77
+ locationCallback = null
78
+ tracking = false
79
+ false
80
+ }
65
81
  }
66
82
 
67
83
  fun stop() {
@@ -72,13 +88,18 @@ class LocationEngine(private val context: Context) {
72
88
 
73
89
  @SuppressLint("MissingPermission")
74
90
  fun getCurrentLocation(callback: (LocationData?) -> Unit) {
75
- fusedClient.lastLocation.addOnSuccessListener { location ->
76
- if (location != null) {
77
- callback(locationToData(location))
78
- } else {
91
+ try {
92
+ fusedClient.lastLocation.addOnSuccessListener { location ->
93
+ if (location != null) {
94
+ callback(locationToData(location))
95
+ } else {
96
+ callback(null)
97
+ }
98
+ }.addOnFailureListener {
79
99
  callback(null)
80
100
  }
81
- }.addOnFailureListener {
101
+ } catch (e: SecurityException) {
102
+ Log.w(TAG, "getCurrentLocation refused — permission missing: ${e.message}")
82
103
  callback(null)
83
104
  }
84
105
  }
@@ -1,13 +1,17 @@
1
1
  package com.margelo.nitro.nitrolocationtracking
2
2
 
3
+ import android.app.Activity
4
+ import android.content.Intent
3
5
  import android.content.pm.PackageManager
4
6
  import android.util.Log
5
7
  import androidx.core.app.ActivityCompat
6
8
  import androidx.core.content.ContextCompat
7
9
  import com.facebook.proguard.annotations.DoNotStrip
10
+ import com.facebook.react.bridge.BaseActivityEventListener
8
11
  import com.facebook.react.bridge.ReactContext
9
12
  import com.margelo.nitro.NitroModules
10
13
  import com.margelo.nitro.core.Promise
14
+ import java.util.concurrent.atomic.AtomicBoolean
11
15
  import kotlin.coroutines.resume
12
16
  import kotlin.coroutines.resumeWithException
13
17
  import kotlin.coroutines.suspendCoroutine
@@ -18,6 +22,7 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
18
22
  companion object {
19
23
  private const val TAG = "NitroLocationTracking"
20
24
  private const val PERMISSION_REQUEST_CODE = 9001
25
+ private const val GPS_RESOLUTION_REQUEST_CODE = 9002
21
26
  }
22
27
 
23
28
  private var locationEngine: LocationEngine? = null
@@ -28,6 +33,7 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
28
33
  private var providerStatusMonitor: ProviderStatusMonitor? = null
29
34
  private var permissionStatusMonitor: PermissionStatusMonitor? = null
30
35
  private var mockLocationMonitor: MockLocationMonitor? = null
36
+ private var airplaneModeMonitor: AirplaneModeMonitor? = null
31
37
 
32
38
  private var locationCallback: ((LocationData) -> Unit)? = null
33
39
  private var motionCallback: ((Boolean) -> Unit)? = null
@@ -38,6 +44,7 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
38
44
  private var providerStatusCallback: ((LocationProviderStatus, LocationProviderStatus) -> Unit)? = null
39
45
  private var permissionStatusCallback: ((PermissionStatus) -> Unit)? = null
40
46
  private var mockLocationCallback: ((Boolean) -> Unit)? = null
47
+ private var airplaneModeCallback: ((Boolean) -> Unit)? = null
41
48
 
42
49
  private var locationConfig: LocationConfig? = null
43
50
 
@@ -56,8 +63,29 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
56
63
  providerStatusMonitor = ProviderStatusMonitor(context)
57
64
  permissionStatusMonitor = PermissionStatusMonitor(context)
58
65
  mockLocationMonitor = MockLocationMonitor(context)
66
+ airplaneModeMonitor = AirplaneModeMonitor(context)
59
67
  locationEngine?.dbWriter = dbWriter
60
68
  connectionManager.dbWriter = dbWriter
69
+
70
+ // Always watch for permission revocation so we can proactively tear
71
+ // down tracking + the foreground service before the OS kills us for
72
+ // holding a location-type FGS without the matching permission.
73
+ permissionStatusMonitor?.setInternalCallback { status ->
74
+ if (status == PermissionStatus.DENIED || status == PermissionStatus.RESTRICTED) {
75
+ Log.w(TAG, "Location permission revoked — stopping tracking and foreground service")
76
+ try {
77
+ locationEngine?.stop()
78
+ } catch (e: Exception) {
79
+ Log.w(TAG, "Error stopping location engine: ${e.message}")
80
+ }
81
+ try {
82
+ notificationService?.stopForegroundService()
83
+ } catch (e: Exception) {
84
+ Log.w(TAG, "Error stopping foreground service: ${e.message}")
85
+ }
86
+ }
87
+ }
88
+
61
89
  Log.d(TAG, "Components initialized successfully")
62
90
  return true
63
91
  }
@@ -78,6 +106,20 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
78
106
  Log.e(TAG, "startTracking failed — could not initialize components")
79
107
  return
80
108
  }
109
+
110
+ // Permission guard. Starting a foreground service of type `location`
111
+ // without holding ACCESS_FINE_LOCATION throws SecurityException on
112
+ // Android 14+. And even on older versions, requestLocationUpdates
113
+ // will throw SecurityException. Fail closed and notify JS instead
114
+ // of crashing the process.
115
+ val permissionStatus = getLocationPermissionStatus()
116
+ if (permissionStatus == PermissionStatus.DENIED ||
117
+ permissionStatus == PermissionStatus.RESTRICTED) {
118
+ Log.w(TAG, "startTracking aborted — location permission is $permissionStatus")
119
+ permissionStatusCallback?.invoke(permissionStatus)
120
+ return
121
+ }
122
+
81
123
  val engine = locationEngine ?: return
82
124
  engine.onLocation = { data ->
83
125
  locationCallback?.invoke(data)
@@ -85,7 +127,14 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
85
127
  engine.onMotionChange = { isMoving ->
86
128
  motionCallback?.invoke(isMoving)
87
129
  }
88
- engine.start(config)
130
+ val started = engine.start(config)
131
+ if (!started) {
132
+ // engine.start() already logged the reason. Don't start the FGS
133
+ // if tracking itself could not be started — the FGS would just
134
+ // be killed immediately by the OS.
135
+ Log.w(TAG, "startTracking aborted — location engine refused to start")
136
+ return
137
+ }
89
138
 
90
139
  try {
91
140
  notificationService?.startForegroundService(
@@ -94,6 +143,12 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
94
143
  )
95
144
  } catch (e: SecurityException) {
96
145
  Log.w(TAG, "Could not start foreground service — missing runtime permissions: ${e.message}")
146
+ // Roll back the tracking session so we don't leak a location
147
+ // request with no owning foreground service.
148
+ try { engine.stop() } catch (_: Exception) {}
149
+ } catch (e: Exception) {
150
+ Log.w(TAG, "Could not start foreground service: ${e.message}")
151
+ try { engine.stop() } catch (_: Exception) {}
97
152
  }
98
153
  }
99
154
 
@@ -364,6 +419,122 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
364
419
  }
365
420
  }
366
421
 
422
+ override fun openLocationSettings(): Promise<Boolean> {
423
+ return Promise.async {
424
+ suspendCoroutine { cont ->
425
+ val context = NitroModules.applicationContext
426
+ if (context == null) {
427
+ cont.resume(false)
428
+ return@suspendCoroutine
429
+ }
430
+
431
+ val reactContext = context as? ReactContext
432
+ val activity = reactContext?.currentActivity
433
+ if (reactContext == null || activity == null) {
434
+ Log.w(TAG, "openLocationSettings — no current Activity, using fallback")
435
+ openLocationSettingsFallback(context)
436
+ cont.resume(isLocationServicesEnabled())
437
+ return@suspendCoroutine
438
+ }
439
+
440
+ // Use a high-accuracy location request for the settings check.
441
+ // These values are internal to the SettingsClient call — they do
442
+ // not affect the tracking configuration.
443
+ val locationRequest = com.google.android.gms.location.LocationRequest.Builder(
444
+ com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY,
445
+ 10_000L
446
+ ).build()
447
+
448
+ val settingsRequest = com.google.android.gms.location.LocationSettingsRequest.Builder()
449
+ .addLocationRequest(locationRequest)
450
+ .setAlwaysShow(true)
451
+ .build()
452
+
453
+ val client = com.google.android.gms.location.LocationServices.getSettingsClient(context)
454
+ val task = client.checkLocationSettings(settingsRequest)
455
+
456
+ // Guard against double-resume — the continuation may otherwise
457
+ // be resumed by both the success listener and the activity
458
+ // result listener in rare race conditions.
459
+ val resumed = AtomicBoolean(false)
460
+ fun safeResume(value: Boolean) {
461
+ if (resumed.compareAndSet(false, true)) {
462
+ cont.resume(value)
463
+ }
464
+ }
465
+
466
+ task.addOnSuccessListener {
467
+ // Location settings are already satisfied — GPS is on.
468
+ safeResume(true)
469
+ }
470
+
471
+ task.addOnFailureListener { exception ->
472
+ if (exception is com.google.android.gms.common.api.ResolvableApiException) {
473
+ // Register the activity result listener BEFORE showing
474
+ // the dialog so we cannot miss the result.
475
+ val listener = object : BaseActivityEventListener() {
476
+ override fun onActivityResult(
477
+ activity: Activity?,
478
+ requestCode: Int,
479
+ resultCode: Int,
480
+ data: Intent?
481
+ ) {
482
+ if (requestCode != GPS_RESOLUTION_REQUEST_CODE) return
483
+ reactContext.removeActivityEventListener(this)
484
+ // RESULT_OK = user tapped "OK" in the dialog and GPS is now on.
485
+ // RESULT_CANCELED = user tapped "No thanks" or dismissed.
486
+ val enabled = resultCode == Activity.RESULT_OK
487
+ safeResume(enabled)
488
+ }
489
+ }
490
+ reactContext.addActivityEventListener(listener)
491
+
492
+ try {
493
+ exception.startResolutionForResult(
494
+ activity, GPS_RESOLUTION_REQUEST_CODE
495
+ )
496
+ } catch (e: Exception) {
497
+ Log.w(TAG, "Failed to show resolution dialog: ${e.message}")
498
+ reactContext.removeActivityEventListener(listener)
499
+ openLocationSettingsFallback(context)
500
+ safeResume(isLocationServicesEnabled())
501
+ }
502
+ } else {
503
+ // Not resolvable (e.g. SETTINGS_CHANGE_UNAVAILABLE on an
504
+ // airplane-mode-locked device) — fall back to the system
505
+ // settings screen.
506
+ Log.w(TAG, "Location settings not resolvable: ${exception.message}")
507
+ openLocationSettingsFallback(context)
508
+ safeResume(isLocationServicesEnabled())
509
+ }
510
+ }
511
+ }
512
+ }
513
+ }
514
+
515
+ private fun openLocationSettingsFallback(context: android.content.Context) {
516
+ val intent = android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
517
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
518
+ try {
519
+ context.startActivity(intent)
520
+ } catch (e: Exception) {
521
+ Log.e(TAG, "Failed to open location settings: ${e.message}")
522
+ }
523
+ }
524
+
525
+ // === Device State Monitoring ===
526
+
527
+ override fun isAirplaneModeEnabled(): Boolean {
528
+ ensureInitialized()
529
+ return airplaneModeMonitor?.isAirplaneModeEnabled() ?: false
530
+ }
531
+
532
+ override fun onAirplaneModeChange(callback: (isEnabled: Boolean) -> Unit) {
533
+ airplaneModeCallback = callback
534
+ ensureInitialized()
535
+ airplaneModeMonitor?.setCallback(callback)
536
+ }
537
+
367
538
  // === Distance Utilities ===
368
539
 
369
540
  override fun getDistanceBetween(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
@@ -399,5 +570,6 @@ class NitroLocationTracking : HybridNitroLocationTrackingSpec() {
399
570
  providerStatusMonitor?.destroy()
400
571
  permissionStatusMonitor?.destroy()
401
572
  mockLocationMonitor?.destroy()
573
+ airplaneModeMonitor?.destroy()
402
574
  }
403
575
  }
@@ -25,7 +25,9 @@ class PermissionStatusMonitor(private val context: Context) {
25
25
  }
26
26
 
27
27
  private var callback: ((PermissionStatus) -> Unit)? = null
28
+ private var internalCallback: ((PermissionStatus) -> Unit)? = null
28
29
  private var lastStatus: PermissionStatus? = null
30
+ private var observerRegistered = false
29
31
  private val mainHandler = Handler(Looper.getMainLooper())
30
32
 
31
33
  private val lifecycleObserver = object : DefaultLifecycleObserver {
@@ -36,8 +38,25 @@ class PermissionStatusMonitor(private val context: Context) {
36
38
 
37
39
  fun setCallback(callback: (PermissionStatus) -> Unit) {
38
40
  this.callback = callback
41
+ ensureObserverRegistered()
42
+ }
43
+
44
+ /**
45
+ * Internal callback used by the native module to react to permission loss
46
+ * (e.g. auto-stop tracking) independently of any JS listener. Registering
47
+ * this also starts the lifecycle observer, so native cleanup works even
48
+ * when the user has not called onPermissionStatusChange() from JS.
49
+ */
50
+ fun setInternalCallback(callback: (PermissionStatus) -> Unit) {
51
+ this.internalCallback = callback
52
+ ensureObserverRegistered()
53
+ }
54
+
55
+ private fun ensureObserverRegistered() {
56
+ if (observerRegistered) return
39
57
  // Capture the current status so we only fire on actual changes
40
58
  lastStatus = getCurrentPermissionStatus()
59
+ observerRegistered = true
41
60
 
42
61
  // addObserver MUST be called on the main thread
43
62
  mainHandler.post {
@@ -46,6 +65,7 @@ class PermissionStatusMonitor(private val context: Context) {
46
65
  Log.d(TAG, "Registered lifecycle observer for permission changes")
47
66
  } catch (e: Exception) {
48
67
  Log.e(TAG, "Failed to register lifecycle observer: ${e.message}")
68
+ observerRegistered = false
49
69
  }
50
70
  }
51
71
  }
@@ -55,6 +75,10 @@ class PermissionStatusMonitor(private val context: Context) {
55
75
  if (current != lastStatus) {
56
76
  Log.d(TAG, "Permission status changed: $lastStatus -> $current")
57
77
  lastStatus = current
78
+ // Native cleanup first, JS notification second — so by the time JS
79
+ // hears about the denial, the native side has already released
80
+ // resources (tracking session, foreground service).
81
+ internalCallback?.invoke(current)
58
82
  callback?.invoke(current)
59
83
  }
60
84
  }
@@ -86,7 +110,9 @@ class PermissionStatusMonitor(private val context: Context) {
86
110
  ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
87
111
  } catch (_: Exception) {}
88
112
  }
113
+ observerRegistered = false
89
114
  callback = null
115
+ internalCallback = null
90
116
  lastStatus = null
91
117
  }
92
118
  }