react-native-nitro-compass 1.0.9 → 1.2.0

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 (47) hide show
  1. package/README.md +166 -20
  2. package/android/src/main/java/com/margelo/nitro/nitrocompass/HybridNitroCompass.kt +718 -105
  3. package/ios/HybridNitroCompass.swift +119 -6
  4. package/lib/commonjs/hook.js +102 -11
  5. package/lib/commonjs/hook.js.map +1 -1
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/multiplex.js +23 -2
  8. package/lib/commonjs/multiplex.js.map +1 -1
  9. package/lib/module/hook.js +103 -12
  10. package/lib/module/hook.js.map +1 -1
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/multiplex.js +23 -2
  13. package/lib/module/multiplex.js.map +1 -1
  14. package/lib/typescript/src/hook.d.ts +49 -1
  15. package/lib/typescript/src/hook.d.ts.map +1 -1
  16. package/lib/typescript/src/index.d.ts +2 -2
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/lib/typescript/src/multiplex.d.ts.map +1 -1
  19. package/lib/typescript/src/specs/NitroCompass.nitro.d.ts +158 -18
  20. package/lib/typescript/src/specs/NitroCompass.nitro.d.ts.map +1 -1
  21. package/nitrogen/generated/android/c++/JCompassSample.hpp +7 -3
  22. package/nitrogen/generated/android/c++/JDebugInfo.hpp +85 -0
  23. package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.cpp +21 -0
  24. package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.hpp +4 -0
  25. package/nitrogen/generated/android/c++/JSensorKind.hpp +6 -3
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/CompassSample.kt +9 -4
  27. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/DebugInfo.kt +86 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/HybridNitroCompassSpec.kt +16 -0
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/SensorKind.kt +4 -3
  30. package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Bridge.hpp +12 -0
  31. package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Umbrella.hpp +3 -0
  32. package/nitrogen/generated/ios/c++/HybridNitroCompassSpecSwift.hpp +29 -0
  33. package/nitrogen/generated/ios/swift/CompassSample.swift +7 -2
  34. package/nitrogen/generated/ios/swift/DebugInfo.swift +64 -0
  35. package/nitrogen/generated/ios/swift/HybridNitroCompassSpec.swift +4 -0
  36. package/nitrogen/generated/ios/swift/HybridNitroCompassSpec_cxx.swift +45 -0
  37. package/nitrogen/generated/ios/swift/SensorKind.swift +8 -4
  38. package/nitrogen/generated/shared/c++/CompassSample.hpp +6 -2
  39. package/nitrogen/generated/shared/c++/DebugInfo.hpp +111 -0
  40. package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.cpp +4 -0
  41. package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.hpp +7 -0
  42. package/nitrogen/generated/shared/c++/SensorKind.hpp +10 -6
  43. package/package.json +2 -2
  44. package/src/hook.ts +161 -12
  45. package/src/index.ts +2 -0
  46. package/src/multiplex.ts +23 -2
  47. package/src/specs/NitroCompass.nitro.ts +164 -18
@@ -18,13 +18,17 @@ namespace margelo::nitro::nitrocompass {
18
18
  prototype.registerHybridMethod("stop", &HybridNitroCompassSpec::stop);
19
19
  prototype.registerHybridMethod("isStarted", &HybridNitroCompassSpec::isStarted);
20
20
  prototype.registerHybridMethod("setFilter", &HybridNitroCompassSpec::setFilter);
21
+ prototype.registerHybridMethod("setSmoothing", &HybridNitroCompassSpec::setSmoothing);
21
22
  prototype.registerHybridMethod("getDiagnostics", &HybridNitroCompassSpec::getDiagnostics);
23
+ prototype.registerHybridMethod("getDebugInfo", &HybridNitroCompassSpec::getDebugInfo);
22
24
  prototype.registerHybridMethod("hasCompass", &HybridNitroCompassSpec::hasCompass);
23
25
  prototype.registerHybridMethod("getCurrentHeading", &HybridNitroCompassSpec::getCurrentHeading);
24
26
  prototype.registerHybridMethod("setDeclination", &HybridNitroCompassSpec::setDeclination);
27
+ prototype.registerHybridMethod("setLocation", &HybridNitroCompassSpec::setLocation);
25
28
  prototype.registerHybridMethod("setOnCalibrationNeeded", &HybridNitroCompassSpec::setOnCalibrationNeeded);
26
29
  prototype.registerHybridMethod("setOnInterferenceDetected", &HybridNitroCompassSpec::setOnInterferenceDetected);
27
30
  prototype.registerHybridMethod("setPauseOnBackground", &HybridNitroCompassSpec::setPauseOnBackground);
31
+ prototype.registerHybridMethod("recalibrate", &HybridNitroCompassSpec::recalibrate);
28
32
  prototype.registerHybridMethod("getPermissionStatus", &HybridNitroCompassSpec::getPermissionStatus);
29
33
  prototype.registerHybridMethod("requestPermission", &HybridNitroCompassSpec::requestPermission);
30
34
  });
@@ -17,6 +17,8 @@
17
17
  namespace margelo::nitro::nitrocompass { struct CompassSample; }
18
18
  // Forward declaration of `SensorDiagnostics` to properly resolve imports.
19
19
  namespace margelo::nitro::nitrocompass { struct SensorDiagnostics; }
20
+ // Forward declaration of `DebugInfo` to properly resolve imports.
21
+ namespace margelo::nitro::nitrocompass { struct DebugInfo; }
20
22
  // Forward declaration of `AccuracyQuality` to properly resolve imports.
21
23
  namespace margelo::nitro::nitrocompass { enum class AccuracyQuality; }
22
24
  // Forward declaration of `PermissionStatus` to properly resolve imports.
@@ -26,6 +28,7 @@ namespace margelo::nitro::nitrocompass { enum class PermissionStatus; }
26
28
  #include <functional>
27
29
  #include "SensorDiagnostics.hpp"
28
30
  #include <optional>
31
+ #include "DebugInfo.hpp"
29
32
  #include "AccuracyQuality.hpp"
30
33
  #include "PermissionStatus.hpp"
31
34
  #include <NitroModules/Promise.hpp>
@@ -65,13 +68,17 @@ namespace margelo::nitro::nitrocompass {
65
68
  virtual void stop() = 0;
66
69
  virtual bool isStarted() = 0;
67
70
  virtual void setFilter(double degrees) = 0;
71
+ virtual void setSmoothing(double alpha) = 0;
68
72
  virtual std::optional<SensorDiagnostics> getDiagnostics() = 0;
73
+ virtual DebugInfo getDebugInfo() = 0;
69
74
  virtual bool hasCompass() = 0;
70
75
  virtual std::optional<CompassSample> getCurrentHeading() = 0;
71
76
  virtual void setDeclination(double degrees) = 0;
77
+ virtual void setLocation(double latitude, double longitude) = 0;
72
78
  virtual void setOnCalibrationNeeded(const std::function<void(AccuracyQuality /* quality */)>& onChange) = 0;
73
79
  virtual void setOnInterferenceDetected(const std::function<void(bool /* interferenceDetected */)>& onChange) = 0;
74
80
  virtual void setPauseOnBackground(bool enabled) = 0;
81
+ virtual void recalibrate() = 0;
75
82
  virtual PermissionStatus getPermissionStatus() = 0;
76
83
  virtual std::shared_ptr<Promise<PermissionStatus>> requestPermission() = 0;
77
84
 
@@ -29,9 +29,10 @@ namespace margelo::nitro::nitrocompass {
29
29
  * An enum which can be represented as a JavaScript union (SensorKind).
30
30
  */
31
31
  enum class SensorKind {
32
- ROTATIONVECTOR SWIFT_NAME(rotationvector) = 0,
33
- GEOMAGNETICROTATIONVECTOR SWIFT_NAME(geomagneticrotationvector) = 1,
34
- CORELOCATION SWIFT_NAME(corelocation) = 2,
32
+ MAGNETOMETER SWIFT_NAME(magnetometer) = 0,
33
+ CORELOCATION SWIFT_NAME(corelocation) = 1,
34
+ ROTATIONVECTOR SWIFT_NAME(rotationvector) = 2,
35
+ GEOMAGNETICROTATIONVECTOR SWIFT_NAME(geomagneticrotationvector) = 3,
35
36
  } CLOSED_ENUM;
36
37
 
37
38
  } // namespace margelo::nitro::nitrocompass
@@ -44,18 +45,20 @@ namespace margelo::nitro {
44
45
  static inline margelo::nitro::nitrocompass::SensorKind fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) {
45
46
  std::string unionValue = JSIConverter<std::string>::fromJSI(runtime, arg);
46
47
  switch (hashString(unionValue.c_str(), unionValue.size())) {
48
+ case hashString("magnetometer"): return margelo::nitro::nitrocompass::SensorKind::MAGNETOMETER;
49
+ case hashString("coreLocation"): return margelo::nitro::nitrocompass::SensorKind::CORELOCATION;
47
50
  case hashString("rotationVector"): return margelo::nitro::nitrocompass::SensorKind::ROTATIONVECTOR;
48
51
  case hashString("geomagneticRotationVector"): return margelo::nitro::nitrocompass::SensorKind::GEOMAGNETICROTATIONVECTOR;
49
- case hashString("coreLocation"): return margelo::nitro::nitrocompass::SensorKind::CORELOCATION;
50
52
  default: [[unlikely]]
51
53
  throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum SensorKind - invalid value!");
52
54
  }
53
55
  }
54
56
  static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::nitrocompass::SensorKind arg) {
55
57
  switch (arg) {
58
+ case margelo::nitro::nitrocompass::SensorKind::MAGNETOMETER: return JSIConverter<std::string>::toJSI(runtime, "magnetometer");
59
+ case margelo::nitro::nitrocompass::SensorKind::CORELOCATION: return JSIConverter<std::string>::toJSI(runtime, "coreLocation");
56
60
  case margelo::nitro::nitrocompass::SensorKind::ROTATIONVECTOR: return JSIConverter<std::string>::toJSI(runtime, "rotationVector");
57
61
  case margelo::nitro::nitrocompass::SensorKind::GEOMAGNETICROTATIONVECTOR: return JSIConverter<std::string>::toJSI(runtime, "geomagneticRotationVector");
58
- case margelo::nitro::nitrocompass::SensorKind::CORELOCATION: return JSIConverter<std::string>::toJSI(runtime, "coreLocation");
59
62
  default: [[unlikely]]
60
63
  throw std::invalid_argument("Cannot convert SensorKind to JS - invalid value: "
61
64
  + std::to_string(static_cast<int>(arg)) + "!");
@@ -67,9 +70,10 @@ namespace margelo::nitro {
67
70
  }
68
71
  std::string unionValue = JSIConverter<std::string>::fromJSI(runtime, value);
69
72
  switch (hashString(unionValue.c_str(), unionValue.size())) {
73
+ case hashString("magnetometer"):
74
+ case hashString("coreLocation"):
70
75
  case hashString("rotationVector"):
71
76
  case hashString("geomagneticRotationVector"):
72
- case hashString("coreLocation"):
73
77
  return true;
74
78
  default:
75
79
  return false;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "react-native-nitro-compass",
3
- "version": "1.0.9",
3
+ "version": "1.2.0",
4
4
  "engines": {
5
5
  "node": ">=18"
6
6
  },
7
- "description": "Fast, accurate compass heading for React Native, powered by Nitro Modules. Uses Android TYPE_ROTATION_VECTOR sensor fusion and iOS CLHeading.",
7
+ "description": "Fast, accurate compass heading for React Native, powered by Nitro Modules. Uses raw Android accelerometer + magnetometer fusion (instant interference recovery) and iOS CLHeading.",
8
8
  "main": "./lib/commonjs/index.js",
9
9
  "module": "./lib/module/index.js",
10
10
  "types": "./lib/typescript/src/index.d.ts",
package/src/hook.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import type {
3
3
  AccuracyQuality,
4
4
  CompassSample,
5
+ PermissionStatus,
5
6
  SensorDiagnostics,
6
7
  } from './specs/NitroCompass.nitro'
7
8
  import { NitroCompass } from './native'
@@ -19,6 +20,16 @@ export interface UseCompassOptions {
19
20
  * the library — last-write-wins.
20
21
  */
21
22
  filterDegrees?: number
23
+ /**
24
+ * Low-pass smoothing factor (EMA α) applied to heading samples.
25
+ * Range `(0, 1]`. Default `0.2` ≈ 100ms time constant at typical
26
+ * Android sample rates. `1.0` disables smoothing. Smaller values
27
+ * smooth more (kills jitter, adds a touch of latency).
28
+ *
29
+ * No-op on iOS — CLLocationManager filters internally.
30
+ * Shared global state — last-write-wins.
31
+ */
32
+ smoothingAlpha?: number
22
33
  /**
23
34
  * Magnetic-to-true offset in signed degrees. Default `0` (magnetic).
24
35
  * Pull from a model like `geomagnetism` keyed on the user's lat/lon.
@@ -50,6 +61,44 @@ export interface UseCompassResult {
50
61
  hasCompass: boolean
51
62
  /** Which sensor backs the readings on this device. */
52
63
  diagnostics: SensorDiagnostics | undefined
64
+ /**
65
+ * Latest platform permission status. Always `'granted'` on Android.
66
+ * On iOS may transition from `'unknown'` → `'granted'`/`'denied'`
67
+ * after `requestPermission()` resolves.
68
+ */
69
+ permission: PermissionStatus
70
+ /**
71
+ * Synchronous read of the most recent emitted sample (with declination
72
+ * already applied), or `undefined` if not started yet or no sample
73
+ * has arrived. Useful inside event handlers without re-rendering.
74
+ */
75
+ getCurrentHeading: () => CompassSample | undefined
76
+ /**
77
+ * Force a best-effort sensor recalibration. On Android this re-registers
78
+ * the sensor listeners (often nudges the magnetometer driver to
79
+ * re-evaluate calibration); on iOS it dismisses the heading-calibration
80
+ * overlay and stop/restarts heading updates.
81
+ */
82
+ recalibrate: () => void
83
+ /**
84
+ * Set the user's geographic location for a tighter interference gate.
85
+ * Android uses the WMM2025 model bundled in `GeomagneticField` to
86
+ * derive the expected field strength at the location; iOS is a no-op
87
+ * because `CLLocationManager` already uses GPS-derived location
88
+ * internally for all field-related reasoning. Pass `NaN` or
89
+ * out-of-range values to revert to the generic 20–70 µT band.
90
+ */
91
+ setLocation: (latitude: number, longitude: number) => void
92
+ /**
93
+ * Request the platform permission required to deliver headings.
94
+ * Android resolves immediately with `'granted'`. On iOS this prompts
95
+ * the system "Allow location" dialog if the status is `'unknown'`,
96
+ * resolving once the user makes a choice; subsequent calls resolve
97
+ * immediately with the cached status (iOS does not re-prompt). The
98
+ * hook's `permission` field updates automatically when the promise
99
+ * resolves.
100
+ */
101
+ requestPermission: () => Promise<PermissionStatus>
53
102
  }
54
103
 
55
104
  /**
@@ -64,6 +113,7 @@ export function useCompass(
64
113
  ): UseCompassResult {
65
114
  const {
66
115
  filterDegrees = 1,
116
+ smoothingAlpha = 0.2,
67
117
  declination = 0,
68
118
  pauseOnBackground = true,
69
119
  enabled = true,
@@ -73,19 +123,48 @@ export function useCompass(
73
123
  const [quality, setQuality] = useState<AccuracyQuality | null>(null)
74
124
  const [interfering, setInterfering] = useState(false)
75
125
 
76
- const [hasCompass] = useState(() => NitroCompass.hasCompass())
77
- const [diagnostics] = useState(() => NitroCompass.getDiagnostics())
126
+ // Wrap in try/catch so a missing/misconfigured native module doesn't
127
+ // throw during render return safe defaults and let the host UI
128
+ // surface "no compass". Without this, the throw bubbles up and
129
+ // becomes a render error that blanks the screen.
130
+ const [hasCompass] = useState(() => {
131
+ try {
132
+ return NitroCompass.hasCompass()
133
+ } catch {
134
+ return false
135
+ }
136
+ })
137
+ const [diagnostics] = useState(() => {
138
+ try {
139
+ return NitroCompass.getDiagnostics()
140
+ } catch {
141
+ return undefined
142
+ }
143
+ })
144
+ const [permission, setPermission] = useState<PermissionStatus>(() => {
145
+ try {
146
+ return NitroCompass.getPermissionStatus()
147
+ } catch {
148
+ return 'unknown'
149
+ }
150
+ })
78
151
 
79
- // Tracked via ref so the heading-subscription effect can re-apply
80
- // the user's filter after a stop/start cycle without restarting on
81
- // every filterDegrees change.
152
+ // Tracked via refs so the heading-subscription effect can re-apply
153
+ // the user's filter and smoothing after a stop/start cycle without
154
+ // restarting on every option change.
82
155
  const filterRef = useRef(filterDegrees)
83
156
  filterRef.current = filterDegrees
157
+ const smoothingRef = useRef(smoothingAlpha)
158
+ smoothingRef.current = smoothingAlpha
84
159
 
85
160
  useEffect(() => {
86
161
  NitroCompass.setFilter(filterDegrees)
87
162
  }, [filterDegrees])
88
163
 
164
+ useEffect(() => {
165
+ NitroCompass.setSmoothing(smoothingAlpha)
166
+ }, [smoothingAlpha])
167
+
89
168
  useEffect(() => {
90
169
  NitroCompass.setDeclination(declination)
91
170
  }, [declination])
@@ -105,14 +184,84 @@ export function useCompass(
105
184
  }, [hasCompass])
106
185
 
107
186
  useEffect(() => {
108
- if (!hasCompass || !enabled) return
109
- const off = addHeadingListener(setReading)
110
- // Multiplex starts the sensor with a default filter; re-apply the
111
- // current option after subscribing.
112
- NitroCompass.setFilter(filterRef.current)
187
+ if (!hasCompass || !enabled) {
188
+ // When the user disables the hook, clear stale UI state.
189
+ // Without this, `reading` / `interfering` keep their last value
190
+ // forever, so the consumer's UI can't tell "subscription is off"
191
+ // from "compass is currently quiet".
192
+ setReading(null)
193
+ setInterfering(false)
194
+ return
195
+ }
196
+ let off: (() => void) | undefined
197
+ try {
198
+ off = addHeadingListener(setReading)
199
+ // Multiplex starts the sensor with a default filter; re-apply
200
+ // the current options after subscribing. recalibrate() can also
201
+ // reset native smoothing state, so we pin our chosen alpha back
202
+ // here as well.
203
+ NitroCompass.setFilter(filterRef.current)
204
+ NitroCompass.setSmoothing(smoothingRef.current)
205
+ } catch (e) {
206
+ // start() throws on iOS when location authorization is denied.
207
+ // Swallow it here so the hook tree doesn't unmount; consumers
208
+ // should call NitroCompass.requestPermission() explicitly before
209
+ // setting enabled=true if they want to recover.
210
+ // eslint-disable-next-line no-console
211
+ console.warn('[NitroCompass] failed to start heading subscription:', e)
212
+ }
113
213
  return off
114
214
  // eslint-disable-next-line react-hooks/exhaustive-deps
115
215
  }, [hasCompass, enabled])
116
216
 
117
- return { reading, quality, interfering, hasCompass, diagnostics }
217
+ // Stable callbacks so consumers' useEffect deps don't churn on every
218
+ // render. All four are thin wrappers around NitroCompass; the hook's
219
+ // own state isn't reactive to recalibrate/setLocation since neither
220
+ // changes any field returned here.
221
+ const getCurrentHeading = useCallback(() => {
222
+ try {
223
+ return NitroCompass.getCurrentHeading()
224
+ } catch {
225
+ return undefined
226
+ }
227
+ }, [])
228
+
229
+ const recalibrate = useCallback(() => {
230
+ try {
231
+ NitroCompass.recalibrate()
232
+ } catch {
233
+ // Native missing — nothing to do.
234
+ }
235
+ }, [])
236
+
237
+ const setLocation = useCallback((latitude: number, longitude: number) => {
238
+ try {
239
+ NitroCompass.setLocation(latitude, longitude)
240
+ } catch {
241
+ // Native missing — silently ignored.
242
+ }
243
+ }, [])
244
+
245
+ const requestPermission = useCallback(async (): Promise<PermissionStatus> => {
246
+ try {
247
+ const status = await NitroCompass.requestPermission()
248
+ setPermission(status)
249
+ return status
250
+ } catch {
251
+ return 'denied'
252
+ }
253
+ }, [])
254
+
255
+ return {
256
+ reading,
257
+ quality,
258
+ interfering,
259
+ hasCompass,
260
+ diagnostics,
261
+ permission,
262
+ getCurrentHeading,
263
+ recalibrate,
264
+ setLocation,
265
+ requestPermission,
266
+ }
118
267
  }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AccuracyQuality,
3
3
  CompassSample,
4
+ DebugInfo,
4
5
  NitroCompass as NitroCompassSpec,
5
6
  PermissionStatus,
6
7
  SensorDiagnostics,
@@ -12,6 +13,7 @@ export { NitroCompass } from './native'
12
13
  export type {
13
14
  AccuracyQuality,
14
15
  CompassSample,
16
+ DebugInfo,
15
17
  PermissionStatus,
16
18
  SensorDiagnostics,
17
19
  SensorKind,
package/src/multiplex.ts CHANGED
@@ -82,6 +82,13 @@ export function addHeadingListener(cb: HeadingListener): () => void {
82
82
  }
83
83
  }
84
84
 
85
+ // Module-level no-op kept stable so we can swap it back into the native
86
+ // callback slot when the last listener leaves — releasing references
87
+ // to old dispatcher closures, which matters when the JS module is
88
+ // re-evaluated (Metro Fast Refresh, jest module reset).
89
+ const NOOP_CALIBRATION = (_: AccuracyQuality) => {}
90
+ const NOOP_INTERFERENCE = (_: boolean) => {}
91
+
85
92
  /**
86
93
  * Subscribe to calibration-bucket transitions. Only fires while a
87
94
  * heading subscription is active. Returns the unsubscribe function.
@@ -95,7 +102,17 @@ export function addCalibrationListener(
95
102
  }
96
103
  calibrationListeners.add(cb)
97
104
  return () => {
98
- calibrationListeners.delete(cb)
105
+ if (!calibrationListeners.delete(cb)) return
106
+ if (calibrationListeners.size === 0) {
107
+ // Detach our dispatcher from the native side. Without this, a
108
+ // module reload (Metro Fast Refresh, jest resetModules) leaves
109
+ // the old dispatcher pinned in native memory while a new module
110
+ // load installs a *second* dispatcher pointing at a fresh
111
+ // listener Set — splitting events between the two and silently
112
+ // dropping the now-orphaned listeners.
113
+ NitroCompass.setOnCalibrationNeeded(NOOP_CALIBRATION)
114
+ calibrationRegistered = false
115
+ }
99
116
  }
100
117
  }
101
118
 
@@ -112,6 +129,10 @@ export function addInterferenceListener(
112
129
  }
113
130
  interferenceListeners.add(cb)
114
131
  return () => {
115
- interferenceListeners.delete(cb)
132
+ if (!interferenceListeners.delete(cb)) return
133
+ if (interferenceListeners.size === 0) {
134
+ NitroCompass.setOnInterferenceDetected(NOOP_INTERFERENCE)
135
+ interferenceRegistered = false
136
+ }
116
137
  }
117
138
  }
@@ -2,7 +2,7 @@ import type { HybridObject } from 'react-native-nitro-modules'
2
2
 
3
3
  /**
4
4
  * One compass heading sample, delivered to the JS callback registered with
5
- * `start()`. Both fields are in degrees.
5
+ * `start()`. Angular fields are in degrees; field strength is in microtesla.
6
6
  *
7
7
  * - `heading`: heading clockwise from north, in `[0, 360)`. Magnetic by
8
8
  * default; if you call `setDeclination(deg)` the offset is applied
@@ -10,48 +10,131 @@ import type { HybridObject } from 'react-native-nitro-modules'
10
10
  * `getCurrentHeading()`).
11
11
  * - `accuracy`: estimated heading uncertainty in degrees, or `-1` when the
12
12
  * platform has not yet reported a usable accuracy. Smaller is better.
13
- * On Android this is read from `event.values[4]` of the rotation-vector
14
- * sensor when available, otherwise mapped from `SensorManager.SENSOR_STATUS_*`.
15
- * On iOS this is `CLHeading.headingAccuracy`.
13
+ * On Android, mapped from the magnetometer's `SENSOR_STATUS_*` accuracy
14
+ * bucket (the figure-8 calibration signal). On iOS this is
15
+ * `CLHeading.headingAccuracy`.
16
+ * - `fieldStrengthMicroTesla`: magnitude of the local magnetic field in
17
+ * microteslas (µT), or `-1` when no reading is available yet. Earth's
18
+ * field is normally 25–65 µT; values well outside this band signal
19
+ * external interference (laptops, monitors, magnets, metal). Useful
20
+ * for rendering a "strength" meter à la consumer compass apps.
16
21
  */
17
22
  export interface CompassSample {
18
23
  heading: number
19
24
  accuracy: number
25
+ fieldStrengthMicroTesla: number
20
26
  }
21
27
 
22
28
  /**
23
- * Coarse calibration bucket reported via `setOnCalibrationNeeded`. Buckets
24
- * are derived from numeric heading accuracy on both platforms (same
25
- * thresholds), so values agree across iOS and Android:
29
+ * Coarse calibration bucket reported via `setOnCalibrationNeeded`. The
30
+ * bucket is derived from a numeric heading-accuracy estimate on both
31
+ * platforms, but the thresholds differ because the underlying scales
32
+ * disagree:
26
33
  *
27
- * `<5°` `high`, `<15°` `medium`, `<30°` `low`, otherwise `unreliable`.
34
+ * - **Android** direct mapping from `SensorManager.SENSOR_STATUS_*`:
35
+ * `HIGH` → `high`, `MEDIUM` → `medium`, `LOW` → `low`,
36
+ * `UNRELIABLE`/`NO_CONTACT` → `unreliable`. The numeric `accuracy`
37
+ * field on `CompassSample` is a synthetic upper bound (`<5°`,
38
+ * `<15°`, `<30°`, `-1`).
39
+ * - **iOS** — bucketed from `CLHeading.headingAccuracy` (degrees) with
40
+ * relaxed thresholds because Apple's stack rarely reports under 5°
41
+ * even on a perfectly-calibrated compass:
42
+ * `<20°` → `high`, `<35°` → `medium`, `<55°` → `low`, otherwise
43
+ * `unreliable`. `unreliable` is also reported when iOS asks to
44
+ * display its built-in calibration UI (we suppress the system UI so
45
+ * you can render your own banner).
28
46
  *
29
- * On iOS `unreliable` is also reported when the system asks to display
30
- * its built-in calibration UI (we suppress it).
47
+ * The buckets are intended for UX ("show calibrate prompt") exact
48
+ * cross-platform parity isn't possible because the platforms emit
49
+ * different underlying signals.
31
50
  */
32
51
  export type AccuracyQuality = 'high' | 'medium' | 'low' | 'unreliable'
33
52
 
34
53
  /**
35
54
  * Identifies which underlying sensor / framework is producing headings.
36
55
  *
37
- * - `rotationVector` — Android `Sensor.TYPE_ROTATION_VECTOR` (gyro + accel
38
- * + magnetometer fused). Best quality.
39
- * - `geomagneticRotationVector` Android
40
- * `Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR` (accel + magnetometer only).
41
- * Used as fallback on gyroless / budget devices; lower update rate and
42
- * more susceptible to magnetic interference.
56
+ * - `magnetometer` — Android raw `TYPE_MAGNETIC_FIELD` + `TYPE_ACCELEROMETER`
57
+ * computed via `SensorManager.getRotationMatrix()` + `getOrientation()`.
58
+ * Stateless: snaps back instantly when external interference (magnets,
59
+ * electronics) is removed, instead of waiting for OS-level fusion to
60
+ * re-converge.
43
61
  * - `coreLocation` — iOS `CLLocationManager` heading. Apple's stack
44
62
  * handles fusion natively.
63
+ * - `rotationVector` / `geomagneticRotationVector` — legacy values kept
64
+ * in the union for source compatibility; no longer returned by current
65
+ * builds.
45
66
  */
46
67
  export type SensorKind =
68
+ | 'magnetometer'
69
+ | 'coreLocation'
47
70
  | 'rotationVector'
48
71
  | 'geomagneticRotationVector'
49
- | 'coreLocation'
50
72
 
51
73
  export interface SensorDiagnostics {
52
74
  sensor: SensorKind
53
75
  }
54
76
 
77
+ /**
78
+ * Live introspection of the native compass pipeline. Use for
79
+ * diagnosing user-reported issues (heading wrong, banner stuck,
80
+ * compass frozen) — none of these fields are needed for normal
81
+ * operation.
82
+ *
83
+ * Numeric fields use `-1` (or `NaN` for `fusedYawDeg`) as a
84
+ * "not-applicable / not-yet-available" sentinel; consumers should
85
+ * treat those as missing rather than literal values. Most fields
86
+ * are Android-only — iOS uses `CLLocationManager` and doesn't expose
87
+ * the underlying state, so the iOS implementation reports a minimal
88
+ * subset (`lastFieldMicroTesla`, `interferenceActive`).
89
+ */
90
+ export interface DebugInfo {
91
+ /**
92
+ * Whether the library currently considers external interference to
93
+ * be active. Driven by field-magnitude band checks AND (Android,
94
+ * uncalibrated mag only) recent OS hard-iron-bias jumps.
95
+ */
96
+ interferenceActive: boolean
97
+ /**
98
+ * Milliseconds since the most recent OS hard-iron bias jump on
99
+ * Android's uncalibrated magnetometer. `-1` if never seen.
100
+ * iOS / fallback path: always `-1`.
101
+ */
102
+ msSinceLastBiasJump: number
103
+ /**
104
+ * The expected magnetic field magnitude (µT) at the user's
105
+ * location, derived from `setLocation()`. Used to tighten the
106
+ * interference band. `-1` if `setLocation()` hasn't been called
107
+ * with valid coordinates.
108
+ */
109
+ expectedFieldMicroTesla: number
110
+ /**
111
+ * Most recent measured field magnitude (µT) — same value surfaced
112
+ * on `CompassSample.fieldStrengthMicroTesla`. `-1` if no reading.
113
+ */
114
+ lastFieldMicroTesla: number
115
+ /**
116
+ * Current value of the gyro-corrected fused yaw (deg, [0, 360)).
117
+ * `NaN` before any sample has been processed, or on iOS where
118
+ * gyro fusion is handled inside CLLocationManager.
119
+ */
120
+ fusedYawDeg: number
121
+ /**
122
+ * Latest yaw rate (deg/s) derived from game-rotation-vector
123
+ * deltas. Used to drive the adaptive input low-pass filter.
124
+ * `0` if game-RV is unavailable / hasn't fired yet.
125
+ */
126
+ lastYawRateDegPerS: number
127
+ /** Whether `TYPE_GAME_ROTATION_VECTOR` is currently producing events. Always `false` on iOS. */
128
+ hasGameRotationVector: boolean
129
+ /**
130
+ * Whether Android is sourcing magnetometer data from
131
+ * `TYPE_MAGNETIC_FIELD_UNCALIBRATED` (preferred — bias-jump
132
+ * detection works) vs. the `TYPE_MAGNETIC_FIELD` fallback. Always
133
+ * `false` on iOS.
134
+ */
135
+ usingUncalibratedMag: boolean
136
+ }
137
+
55
138
  /**
56
139
  * Platform permission state required to deliver headings.
57
140
  *
@@ -105,6 +188,23 @@ export interface NitroCompass extends HybridObject<{ ios: 'swift'; android: 'kot
105
188
  */
106
189
  setFilter(degrees: number): void
107
190
 
191
+ /**
192
+ * Set the low-pass smoothing factor (EMA α) applied to heading samples
193
+ * before delivery. Range `(0, 1]`. Default `0.2` ≈ 100ms time constant
194
+ * at Android's typical 50 Hz sample rate.
195
+ *
196
+ * - `1.0` disables smoothing (every sample passes through unfiltered).
197
+ * - Smaller values smooth more — eliminates rotation-vector jitter at
198
+ * the cost of a small amount of latency.
199
+ *
200
+ * Implemented as a circular EMA on `(sin θ, cos θ)` so the 359°→0°
201
+ * wraparound doesn't bias the average. Survives `start`/`stop`.
202
+ *
203
+ * **No-op on iOS.** `CLLocationManager` filters heading internally with
204
+ * Apple's own algorithm; layering EMA on top would only add latency.
205
+ */
206
+ setSmoothing(alpha: number): void
207
+
108
208
  /**
109
209
  * Describe which underlying sensor / framework would produce headings on
110
210
  * this device. Returns `undefined` if the device has no compass hardware
@@ -112,9 +212,17 @@ export interface NitroCompass extends HybridObject<{ ios: 'swift'; android: 'kot
112
212
  */
113
213
  getDiagnostics(): SensorDiagnostics | undefined
114
214
 
215
+ /**
216
+ * Snapshot of the internal compass pipeline. Only intended for
217
+ * diagnosing user-reported issues — see {@link DebugInfo} for
218
+ * field-by-field semantics. Cheap to call (no allocations beyond
219
+ * the returned object); poll at any rate the host UI prefers.
220
+ */
221
+ getDebugInfo(): DebugInfo
222
+
115
223
  /**
116
224
  * Whether the device has the hardware required for a compass reading.
117
- * Android: a rotation-vector sensor (fused or geomagnetic) is present.
225
+ * Android: both a magnetometer and an accelerometer are present.
118
226
  * iOS: `CLLocationManager.headingAvailable()`.
119
227
  */
120
228
  hasCompass(): boolean
@@ -134,6 +242,25 @@ export interface NitroCompass extends HybridObject<{ ios: 'swift'; android: 'kot
134
242
  */
135
243
  setDeclination(degrees: number): void
136
244
 
245
+ /**
246
+ * Set the user's geographic location for a tighter interference
247
+ * gate. With a valid location, the library replaces the generic
248
+ * 20–70 µT "Earth field band" with `expectedField ± 15 µT`, where
249
+ * `expectedField` comes from the WMM2025 model shipped on Android
250
+ * (`GeomagneticField`). This catches weak interference the generic
251
+ * band misses — especially at high/low latitudes where Earth's
252
+ * field is naturally near or above 60 µT.
253
+ *
254
+ * Pass `NaN` for either coordinate, or values outside the valid
255
+ * range (`|lat| > 90`, `|lon| > 180`), to revert to the generic
256
+ * band. Survives across `start`/`stop`.
257
+ *
258
+ * **No-op on iOS.** `CLLocationManager` uses GPS-derived location
259
+ * internally for all field-related reasoning; layering our own
260
+ * lookup on top would be redundant.
261
+ */
262
+ setLocation(latitude: number, longitude: number): void
263
+
137
264
  /**
138
265
  * Register a callback fired when the calibration bucket transitions.
139
266
  * Replaces any previously registered callback. Pass a no-op to mute.
@@ -174,6 +301,25 @@ export interface NitroCompass extends HybridObject<{ ios: 'swift'; android: 'kot
174
301
  */
175
302
  setPauseOnBackground(enabled: boolean): void
176
303
 
304
+ /**
305
+ * Force a best-effort sensor recalibration. Resets internal smoothing
306
+ * and quality-bucket state, then re-registers the underlying sensor
307
+ * listeners. On many Android OEMs the re-registration nudges the
308
+ * magnetometer driver to re-evaluate soft/hard-iron calibration, which
309
+ * unsticks an `UNRELIABLE` bucket that's lingering after a strong
310
+ * magnetic excursion (e.g. another phone placed on top, then removed).
311
+ *
312
+ * On iOS this dismisses the system heading-calibration overlay and
313
+ * stops/restarts heading updates. Apple's stack handles the
314
+ * underlying calibration internally.
315
+ *
316
+ * Idempotent — safe to call when not started, in which case it's a
317
+ * no-op. Calibration recovery still requires the user to move the
318
+ * device through varying orientations; this method just clears the
319
+ * library's cached state so progress is reflected promptly.
320
+ */
321
+ recalibrate(): void
322
+
177
323
  /**
178
324
  * Read the current platform permission state synchronously.
179
325
  * On Android this is always `'granted'` (sensors require no permission);