react-native-nitro-compass 1.1.0 → 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 +141 -20
  2. package/android/src/main/java/com/margelo/nitro/nitrocompass/HybridNitroCompass.kt +654 -133
  3. package/ios/HybridNitroCompass.swift +106 -3
  4. package/lib/commonjs/hook.js +98 -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 +99 -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 +39 -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 +142 -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 +17 -0
  24. package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.hpp +3 -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 +12 -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 +23 -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 +3 -0
  36. package/nitrogen/generated/ios/swift/HybridNitroCompassSpec_cxx.swift +34 -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 +3 -0
  41. package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.hpp +6 -0
  42. package/nitrogen/generated/shared/c++/SensorKind.hpp +10 -6
  43. package/package.json +2 -2
  44. package/src/hook.ts +146 -12
  45. package/src/index.ts +2 -0
  46. package/src/multiplex.ts +23 -2
  47. package/src/specs/NitroCompass.nitro.ts +147 -18
@@ -4,6 +4,7 @@ import android.app.Activity
4
4
  import android.app.ActivityManager
5
5
  import android.app.Application
6
6
  import android.content.Context
7
+ import android.hardware.GeomagneticField
7
8
  import android.hardware.Sensor
8
9
  import android.hardware.SensorEvent
9
10
  import android.hardware.SensorEventListener
@@ -29,24 +30,29 @@ import kotlin.math.sqrt
29
30
  /**
30
31
  * Android implementation of NitroCompass.
31
32
  *
32
- * Uses Sensor.TYPE_ROTATION_VECTOR (gyro+accel+mag fused) with a
33
- * TYPE_GEOMAGNETIC_ROTATION_VECTOR fallback for gyroless / budget devices.
34
- * Sensor delivery happens on a dedicated HandlerThread so it never blocks
35
- * the UI thread; samples are forwarded to the JS callback directly.
33
+ * Computes heading directly from raw `TYPE_ACCELEROMETER` +
34
+ * `TYPE_MAGNETIC_FIELD` via `SensorManager.getRotationMatrix()` /
35
+ * `getOrientation()`. This path is *stateless*: the moment external
36
+ * interference (a magnet, a laptop, etc.) is removed, the very next
37
+ * magnetometer sample produces the correct heading.
36
38
  *
37
- * The math is adapted from the MIT-licensed Andromeda library that powers
38
- * the Trail Sense app: https://github.com/kylecorry31/andromeda
39
+ * The fused `TYPE_ROTATION_VECTOR` sensor would be smoother in steady
40
+ * state, but its OS-level Kalman filter can hold a poisoned bias estimate
41
+ * for many seconds after a strong field excursion — a recovery delay
42
+ * that's the dominant complaint vs. consumer compass apps. Smoothing the
43
+ * raw fix instead, via the existing EMA on (sin θ, cos θ), restores
44
+ * the steady-state feel without the recovery penalty.
39
45
  */
40
46
  @DoNotStrip
41
47
  @Keep
42
48
  class HybridNitroCompass : HybridNitroCompassSpec() {
43
49
 
44
50
  companion object {
45
- // Some Android sensor stacks (notably certain Samsung/Huawei builds)
46
- // can silently stall after a screen off / sensor pressure event.
47
- // The rotation-vector sensor at SENSOR_DELAY_GAME nominally fires
48
- // every ~20ms; if no event has arrived in 1.5s we assume the stack
49
- // froze and force a re-registration.
51
+ // Sensor.TYPE_ACCELEROMETER + TYPE_MAGNETIC_FIELD at SENSOR_DELAY_GAME
52
+ // both fire ~50Hz. If neither has produced an event in 1.5s while
53
+ // the app is foregrounded, assume the sensor stack froze and force
54
+ // a re-registration same watchdog policy as the previous
55
+ // rotation-vector implementation.
50
56
  private const val WATCHDOG_PERIOD_MS = 1_500L
51
57
  private const val STALE_THRESHOLD_NS = 1_500_000_000L
52
58
 
@@ -57,20 +63,74 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
57
63
  private const val EARTH_FIELD_MIN_UT = 20.0
58
64
  private const val EARTH_FIELD_MAX_UT = 70.0
59
65
 
60
- // Default low-pass smoothing for the rotation-vector output. iOS's
61
- // CLLocationManager already filters heading internally; the raw
62
- // Android rotation vector does not, so the dial visibly jitters
63
- // by 1–3° at rest. Smoothing (sin θ, cos θ) instead of θ avoids
64
- // 359°→0° wraparound artifacts. α=0.2 gives a ~5-sample time
65
- // constant — at SENSOR_DELAY_GAME (~20ms) that's ~100ms of lag,
66
- // imperceptible compared to the noise it removes. Tunable live
67
- // via setSmoothing().
66
+ // Default low-pass smoothing for the heading. The raw mag/accel
67
+ // computation produces a usable but noisier signal than the OS's
68
+ // gyro-fused rotation vector, so the dial visibly jitters by 1–3°
69
+ // at rest. Smoothing (sin θ, cos θ) instead of θ avoids 359°→0°
70
+ // wraparound artifacts. α=0.2 gives a ~5-sample time constant — at
71
+ // SENSOR_DELAY_GAME (~20ms) that's ~100ms of lag, imperceptible
72
+ // compared to the noise it removes. Tunable live via setSmoothing().
68
73
  private const val DEFAULT_SMOOTHING_ALPHA = 0.2
74
+
75
+ // Input low-pass on the accel and mag *vectors* before they enter
76
+ // getRotationMatrix(). Killing the noise at its source matters more
77
+ // than smoothing the output heading, because rotation-matrix math
78
+ // amplifies small input noise non-linearly.
79
+ //
80
+ // Per FSensor / Trail-Sense conventions we use *different* α per
81
+ // sensor — accel is jerk-noisy (high-frequency), mag is hard-iron-
82
+ // drift-noisy (low-frequency). And α is *adaptive* on the gyro-
83
+ // derived yaw rate: stronger filter when still (kills steady-state
84
+ // jitter), weaker filter on fast turns (avoids visible lag).
85
+ private const val INPUT_FILTER_ALPHA_ACCEL_STILL = 0.05f
86
+ private const val INPUT_FILTER_ALPHA_ACCEL_FAST = 0.25f
87
+ private const val INPUT_FILTER_ALPHA_MAG_STILL = 0.10f
88
+ private const val INPUT_FILTER_ALPHA_MAG_FAST = 0.40f
89
+ private const val YAW_RATE_STILL_DEG_S = 5.0
90
+ private const val YAW_RATE_FAST_DEG_S = 30.0
91
+
92
+ // Complementary filter blend on yaw: each magnetometer sample pulls
93
+ // the gyro-integrated `fusedYawDeg` toward the mag-derived yaw by
94
+ // this fraction. α=0.02 at 50Hz → time constant ≈ 1 s. Small enough
95
+ // that mag noise is averaged out; large enough that consumer-grade
96
+ // gyro drift can't accumulate visibly before correction. Skipped
97
+ // entirely while interference is active so a magnet doesn't pull
98
+ // the fusion off-truth — gyro carries heading until the field clears.
99
+ private const val MAG_CORRECTION_ALPHA = 0.02
100
+
101
+ // Hard-iron bias jump threshold (µT, per axis). The OS's bias
102
+ // estimate shifts when it observes a sustained change in the local
103
+ // field — placing or removing a magnet on top of the device does
104
+ // exactly this. A jump > 1 µT on any axis means the OS just
105
+ // decided the field environment shifted, which we treat as a soft
106
+ // interference event even when field magnitude stays within the
107
+ // Earth band (e.g. another phone placed on top — moderate
108
+ // perturbation, magnitude check misses it). Only meaningful when
109
+ // we're subscribed to TYPE_MAGNETIC_FIELD_UNCALIBRATED, which
110
+ // reports the bias estimate alongside raw values.
111
+ private const val BIAS_JUMP_UT = 1.0f
112
+ // Grace window after the most recent bias jump. While within this
113
+ // window we treat the mag stack as unsettled (ongoing interference)
114
+ // — gyro alone carries heading until the OS settles its estimate
115
+ // and we trust mag again. 1.5 s comfortably covers OEM bias-update
116
+ // cadence without leaving a long stale interference flag.
117
+ private const val BIAS_JUMP_GRACE_NS = 1_500_000_000L
118
+
119
+ // Tightened interference-band tolerance applied around the
120
+ // user-provided location's expected field strength, when one has
121
+ // been supplied via setLocation(). Earth's field varies from
122
+ // ~25 µT at the equator to ~65 µT near the poles, so a generic
123
+ // 20–70 band is too loose at one extreme and too tight at the
124
+ // other. ±15 µT centered on `expectedFieldUt` catches weak
125
+ // interference while still tolerating sensor noise + altitude
126
+ // variation.
127
+ private const val LOCATION_FIELD_TOLERANCE_UT = 15.0
69
128
  }
70
129
 
71
130
  @Volatile private var filterDeg: Double = 1.0
72
131
  @Volatile private var lastEmittedHeading: Double = Double.NaN
73
132
  @Volatile private var lastAccuracyDeg: Double = -1.0
133
+ @Volatile private var lastFieldUt: Double = -1.0
74
134
  @Volatile private var lastSample: CompassSample? = null
75
135
  @Volatile private var lastQuality: AccuracyQuality? = null
76
136
  @Volatile private var declinationDeg: Double = 0.0
@@ -78,55 +138,127 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
78
138
  @Volatile private var started: Boolean = false
79
139
  @Volatile private var isSubscribed: Boolean = false
80
140
  @Volatile private var lastEventNs: Long = 0L
141
+ // Separate timestamp for game-RV so the watchdog can detect
142
+ // gyro-only freezes (heading silently stops tracking rotation
143
+ // during interference if game-RV freezes alone — the accel+mag
144
+ // pair keeps `lastEventNs` fresh and the watchdog wouldn't fire).
145
+ @Volatile private var lastGameRvEventNs: Long = 0L
81
146
  @Volatile private var lastInterference: Boolean? = null
82
147
  @Volatile private var currentActivityRef: WeakReference<Activity>? = null
83
148
  @Volatile private var smoothedSin: Double = Double.NaN
84
149
  @Volatile private var smoothedCos: Double = Double.NaN
85
150
  @Volatile private var smoothingAlpha: Double = DEFAULT_SMOOTHING_ALPHA
86
- // Tracks whether the rotation-vector sensor publishes a per-sample
87
- // accuracy in `event.values[4]`. Many devices don't, in which case the
88
- // synthetic degree floor derived from SENSOR_STATUS_* is the only
89
- // accuracy signal available — and we have to keep it updated.
90
- @Volatile private var hasPerSampleAccuracy: Boolean = false
91
151
  // Last raw quality from the OS, before the interference downgrade is
92
152
  // applied. Used to re-derive `lastQuality` when interference toggles.
93
153
  @Volatile private var lastRawQuality: AccuracyQuality? = null
154
+ // Snapshot of `lastRawQuality` at the moment interference *started*.
155
+ // The OS magnetometer accuracy bucket can stay at LOW/UNRELIABLE for
156
+ // many seconds after a magnet event (it only refreshes when the user
157
+ // moves the phone enough to recalibrate), but the underlying
158
+ // calibration didn't actually change — the field is back in the
159
+ // Earth band, so the heading is trustworthy. Restoring this snapshot
160
+ // when interference clears lets the calibration banner auto-dismiss
161
+ // without forcing the user to perform a motion or tap Refresh.
162
+ @Volatile private var preInterferenceRawQuality: AccuracyQuality? = null
163
+
164
+ // Latest *low-pass-filtered* accelerometer + magnetometer vectors.
165
+ // Both are needed for every heading computation; we hold a smoothed
166
+ // version of each (input LP via INPUT_FILTER_ALPHA) and recompute
167
+ // heading on every magnetic event. Filtering at the input — before
168
+ // getRotationMatrix runs — kills jitter much more effectively than
169
+ // filtering the output heading, because the rotation-matrix math
170
+ // amplifies small input noise non-linearly.
171
+ private val latestAccel = FloatArray(3)
172
+ private val latestMag = FloatArray(3)
173
+ @Volatile private var hasAccel = false
174
+ @Volatile private var hasMag = false
94
175
 
95
- private val rotationMatrix = FloatArray(16)
96
- private val remappedMatrix = FloatArray(16)
176
+ // Expected magnetic field magnitude at the user's location (µT),
177
+ // computed from `GeomagneticField` (Android's bundled WMM2025) when
178
+ // setLocation() is called. `-1.0` means no location has been
179
+ // supplied — fall back to the generic 20–70 µT band for interference
180
+ // detection.
181
+ @Volatile private var expectedFieldUt: Double = -1.0
182
+
183
+ // Hard-iron bias estimate from TYPE_MAGNETIC_FIELD_UNCALIBRATED.
184
+ // Holds the *previous* event's bias so we can spot jumps; bias jumps
185
+ // happen when the OS revises its hard-iron estimate in response to
186
+ // a changed field environment (e.g. magnet on/off). When we're on
187
+ // the calibrated-mag fallback (no uncalibrated sensor), `hasBias`
188
+ // stays false and these are unused.
189
+ private val lastBias = FloatArray(3)
190
+ @Volatile private var hasBias = false
191
+ @Volatile private var lastBiasJumpNs = 0L
192
+ @Volatile private var usingUncalibratedMag = false
193
+
194
+ private val rotationMatrix = FloatArray(9)
195
+ private val inclinationMatrix = FloatArray(9)
196
+ private val remappedMatrix = FloatArray(9)
97
197
  private val orientation = FloatArray(3)
98
198
 
199
+ // Game-rotation-vector–derived gyro-corrected yaw. Game-RV is
200
+ // gyro+accel only (no mag), so it's *not* poisoned by magnetic
201
+ // events the way TYPE_ROTATION_VECTOR is. We use it as an incremental
202
+ // yaw rate (Δyaw between events) and integrate that into
203
+ // `fusedYawDeg`; magnetometer samples then pull `fusedYawDeg` toward
204
+ // absolute mag-derived yaw via a small complementary blend
205
+ // (MAG_CORRECTION_ALPHA). Net effect: heading tracks fast turns
206
+ // smoothly via gyro, doesn't drift over time thanks to mag, and
207
+ // sails through transient magnet events instead of freezing at
208
+ // last-good. Old/cheap devices may lack a gyro entirely (no game-RV
209
+ // sensor) — we silently fall back to pure mag+accel and behavior is
210
+ // identical to the prior implementation.
211
+ @Volatile private var fusedYawDeg: Double = Double.NaN
212
+ @Volatile private var lastGameRvYawDeg: Double = Double.NaN
213
+ @Volatile private var lastGameRvTimeNs: Long = 0L
214
+ @Volatile private var lastYawRateDegPerS: Double = 0.0
215
+ @Volatile private var hasGameRv: Boolean = false
216
+ // True when interference just cleared and the next mag-derived yaw
217
+ // should snap `fusedYawDeg` directly instead of slowly correcting via
218
+ // MAG_CORRECTION_ALPHA. Without this snap, heading would lag for
219
+ // ~1 s while the complementary filter pulled gyro-integrated truth
220
+ // toward post-magnet truth.
221
+ @Volatile private var seedFusedYaw: Boolean = false
222
+ private val gameRvRotationMatrix = FloatArray(9)
223
+ private val gameRvRemappedMatrix = FloatArray(9)
224
+ private val gameRvOrientation = FloatArray(3)
225
+
99
226
  private val epoch = AtomicInteger(0)
100
227
  private val activityCounter = AtomicInteger(0)
101
228
  private var sensorThread: HandlerThread? = null
102
229
  private var sensorHandler: Handler? = null
103
- private var activeSensor: Sensor? = null
104
230
  private var activeListener: SensorEventListener? = null
105
231
  private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
106
- private var onHeading: ((CompassSample) -> Unit)? = null
107
- private var calibrationCb: ((AccuracyQuality) -> Unit)? = null
108
- private var interferenceCb: ((Boolean) -> Unit)? = null
232
+ // Callback fields are written from the JS thread (start, setOnX) and
233
+ // read from the sensor HandlerThread on every event. @Volatile gives
234
+ // them a happens-before guarantee so a late-registered listener
235
+ // becomes visible to the sensor thread on the next event.
236
+ @Volatile private var onHeading: ((CompassSample) -> Unit)? = null
237
+ @Volatile private var calibrationCb: ((AccuracyQuality) -> Unit)? = null
238
+ @Volatile private var interferenceCb: ((Boolean) -> Unit)? = null
109
239
 
110
240
  private val watchdogHandler = Handler(Looper.getMainLooper())
111
241
  private val watchdogRunnable = object : Runnable {
112
242
  override fun run() {
113
243
  // When the app is backgrounded, the OS legitimately suspends or
114
244
  // throttles non-wake-up sensors (Doze on API 23+, background limits
115
- // on API 26+). Re-registering the listener won't change that — it
116
- // just burns power flapping every 1.5s. Skip the staleness check
117
- // and re-arm; we'll resume normal watchdog behaviour on foreground.
245
+ // on API 26+). Re-registering won't change that — it just burns
246
+ // power flapping every 1.5s. Skip the staleness check and re-arm.
118
247
  val backgrounded = activityCounter.get() == 0
119
- val last = lastEventNs
120
248
  val now = SystemClock.elapsedRealtimeNanos()
121
- if (!backgrounded && last > 0L && now - last > STALE_THRESHOLD_NS) {
249
+ val accelMagStale = lastEventNs > 0L && now - lastEventNs > STALE_THRESHOLD_NS
250
+ // Only watchdog game-RV if we actually have it — older devices
251
+ // without a gyro never produce game-RV events, and we shouldn't
252
+ // re-subscribe everything just because game-RV is silent there.
253
+ val gameRvStale = hasGameRv && lastGameRvEventNs > 0L &&
254
+ now - lastGameRvEventNs > STALE_THRESHOLD_NS
255
+ if (!backgrounded && (accelMagStale || gameRvStale)) {
122
256
  synchronized(this@HybridNitroCompass) {
123
257
  if (started && isSubscribed) {
124
258
  unsubscribeLocked()
125
259
  subscribeLocked()
126
- // subscribeLocked() re-arms the watchdog itself, so don't
127
- // double-post below. Reset the timestamp to give the fresh
128
- // subscription a full window before being judged stale.
129
260
  lastEventNs = SystemClock.elapsedRealtimeNanos()
261
+ lastGameRvEventNs = 0L
130
262
  }
131
263
  }
132
264
  return
@@ -145,15 +277,18 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
145
277
  synchronized(this) {
146
278
  stopLocked()
147
279
  started = true
148
- filterDeg = filterDegrees.coerceAtLeast(0.0)
280
+ // NaN/-Inf would silently freeze heading delivery (`delta < NaN`
281
+ // is always false, suppressing every sample). Coerce to a sane
282
+ // default — same defensive policy as setFilter().
283
+ filterDeg = if (filterDegrees.isFinite()) filterDegrees.coerceAtLeast(0.0) else 0.0
149
284
  this.onHeading = onHeading
150
285
  lastEmittedHeading = Double.NaN
151
286
  lastAccuracyDeg = -1.0
287
+ lastFieldUt = -1.0
152
288
  lastSample = null
153
289
  lastQuality = null
154
-
155
- hasPerSampleAccuracy = false
156
290
  lastRawQuality = null
291
+
157
292
  registerLifecycleCallbacks()
158
293
  subscribeLocked()
159
294
  }
@@ -168,38 +303,87 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
168
303
  override fun hasCompass(): Boolean {
169
304
  val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
170
305
  ?: return false
171
- return sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null ||
172
- sm.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR) != null
306
+ if (sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) == null) return false
307
+ return sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED) != null ||
308
+ sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) != null
173
309
  }
174
310
 
175
311
  override fun isStarted(): Boolean = started
176
312
 
177
313
  override fun setFilter(degrees: Double) {
314
+ // Reject NaN/-Inf: these compare unordered against any value, so
315
+ // the deadband check (`delta < filterDeg`) would silently suppress
316
+ // every sample if filterDeg became NaN.
317
+ if (!degrees.isFinite()) return
178
318
  filterDeg = degrees.coerceAtLeast(0.0)
179
319
  }
180
320
 
181
321
  override fun setSmoothing(alpha: Double) {
182
- smoothingAlpha = alpha.coerceIn(0.0, 1.0)
322
+ // Lower bound is 0.01 (not 0): at α=0 the EMA never updates, so
323
+ // `smoothedSin/Cos` would freeze at the first sample and the
324
+ // surfaced heading would never move — a footgun if a caller
325
+ // computes a small alpha and accidentally rounds to zero.
326
+ if (!alpha.isFinite()) return
327
+ smoothingAlpha = alpha.coerceIn(0.01, 1.0)
183
328
  }
184
329
 
185
330
  override fun getDiagnostics(): SensorDiagnostics? {
186
331
  val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
187
332
  ?: return null
188
- return when {
189
- sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null ->
190
- SensorDiagnostics(SensorKind.ROTATIONVECTOR)
191
- sm.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR) != null ->
192
- SensorDiagnostics(SensorKind.GEOMAGNETICROTATIONVECTOR)
193
- else -> null
333
+ if (sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) == null) return null
334
+ val hasMagSensor = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED) != null ||
335
+ sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) != null
336
+ if (!hasMagSensor) return null
337
+ return SensorDiagnostics(SensorKind.MAGNETOMETER)
338
+ }
339
+
340
+ override fun getDebugInfo(): DebugInfo {
341
+ val msSinceJump = if (lastBiasJumpNs > 0L) {
342
+ (SystemClock.elapsedRealtimeNanos() - lastBiasJumpNs) / 1_000_000.0
343
+ } else {
344
+ -1.0
194
345
  }
346
+ return DebugInfo(
347
+ interferenceActive = lastInterference == true,
348
+ msSinceLastBiasJump = msSinceJump,
349
+ expectedFieldMicroTesla = expectedFieldUt,
350
+ lastFieldMicroTesla = lastFieldUt,
351
+ fusedYawDeg = fusedYawDeg,
352
+ lastYawRateDegPerS = lastYawRateDegPerS,
353
+ hasGameRotationVector = hasGameRv,
354
+ usingUncalibratedMag = usingUncalibratedMag
355
+ )
195
356
  }
196
357
 
197
358
  override fun getCurrentHeading(): CompassSample? = lastSample
198
359
 
199
360
  override fun setDeclination(degrees: Double) {
361
+ // NaN propagates through the emit math (heading + declinationDeg)
362
+ // and would poison every emission until reset.
363
+ if (!degrees.isFinite()) return
200
364
  declinationDeg = degrees
201
365
  }
202
366
 
367
+ override fun setLocation(latitude: Double, longitude: Double) {
368
+ val invalid = latitude.isNaN() || longitude.isNaN() ||
369
+ abs(latitude) > 90.0 || abs(longitude) > 180.0
370
+ expectedFieldUt = if (invalid) {
371
+ // Caller signaled "clear" — revert to the generic 20–70 µT band.
372
+ -1.0
373
+ } else {
374
+ // GeomagneticField returns nT; we work in µT throughout the rest
375
+ // of the file, so divide. Altitude defaults to 0 m — its effect
376
+ // is < 0.03 % per km, well inside the ±15 µT tolerance.
377
+ val gf = GeomagneticField(
378
+ latitude.toFloat(),
379
+ longitude.toFloat(),
380
+ 0f,
381
+ System.currentTimeMillis()
382
+ )
383
+ gf.fieldStrength.toDouble() / 1000.0
384
+ }
385
+ }
386
+
203
387
  override fun setOnCalibrationNeeded(onChange: (quality: AccuracyQuality) -> Unit) {
204
388
  calibrationCb = onChange
205
389
  }
@@ -223,6 +407,46 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
223
407
  }
224
408
  }
225
409
 
410
+ override fun recalibrate() {
411
+ synchronized(this) {
412
+ if (!started) return
413
+ // Re-register the sensor listeners. On many Android OEMs the
414
+ // unregister/register cycle nudges the magnetometer driver into
415
+ // re-evaluating soft/hard-iron calibration, which can unstick an
416
+ // UNRELIABLE bucket that's lingering after a strong field
417
+ // excursion. At minimum it gives the consumer a deterministic
418
+ // "try again" button.
419
+ val wasSubscribed = isSubscribed
420
+ if (wasSubscribed) unsubscribeLocked()
421
+
422
+ // Wipe everything that could carry stale state into the fresh
423
+ // subscription. The next sensor events will reseed the input LP,
424
+ // the output EMA, and the gyro fusion from current truth.
425
+ lastEmittedHeading = Double.NaN
426
+ lastAccuracyDeg = -1.0
427
+ lastFieldUt = -1.0
428
+ lastSample = null
429
+ lastQuality = null
430
+ lastRawQuality = null
431
+ preInterferenceRawQuality = null
432
+ lastInterference = null
433
+ smoothedSin = Double.NaN
434
+ smoothedCos = Double.NaN
435
+ hasAccel = false
436
+ hasMag = false
437
+ hasBias = false
438
+ lastBiasJumpNs = 0L
439
+ fusedYawDeg = Double.NaN
440
+ lastGameRvYawDeg = Double.NaN
441
+ lastGameRvTimeNs = 0L
442
+ lastYawRateDegPerS = 0.0
443
+ hasGameRv = false
444
+ seedFusedYaw = false
445
+
446
+ if (wasSubscribed) subscribeLocked()
447
+ }
448
+ }
449
+
226
450
  // Sensors don't require a runtime permission on Android, so both
227
451
  // permission methods are unconditionally granted.
228
452
  override fun getPermissionStatus(): PermissionStatus = PermissionStatus.GRANTED
@@ -241,15 +465,27 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
241
465
  lastSample = null
242
466
  lastQuality = null
243
467
  lastInterference = null
468
+ preInterferenceRawQuality = null
244
469
  }
245
470
 
246
471
  private fun subscribeLocked() {
247
472
  if (isSubscribed) return
248
473
  val sm = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
249
474
  ?: throw IllegalStateException("SensorManager unavailable")
250
- val sensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
251
- ?: sm.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR)
252
- ?: throw IllegalStateException("No rotation sensor on this device")
475
+ val accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
476
+ ?: throw IllegalStateException("No accelerometer on this device")
477
+ // Prefer TYPE_MAGNETIC_FIELD_UNCALIBRATED it reports the OS's
478
+ // hard-iron bias estimate alongside raw values, so we can detect
479
+ // bias jumps (a much more reliable interference signal than field
480
+ // magnitude alone — catches "weak" magnet events where the
481
+ // magnitude stays in the Earth band but the OS still revises its
482
+ // bias). We apply the bias correction ourselves, so the heading
483
+ // computation is identical to the calibrated path.
484
+ val magUncal = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED)
485
+ val magCal = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
486
+ val mag = magUncal ?: magCal
487
+ ?: throw IllegalStateException("No magnetometer on this device")
488
+ usingUncalibratedMag = magUncal != null
253
489
 
254
490
  val myEpoch = epoch.incrementAndGet()
255
491
  val thread = HandlerThread("NitroCompass-Sensor").also { it.start() }
@@ -262,28 +498,50 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
262
498
 
263
499
  override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
264
500
  if (myEpoch != epoch.get()) return
265
- handleAccuracyChanged(accuracy)
501
+ // The magnetometer's accuracy bucket is the figure-8 calibration
502
+ // signal we want to surface. Accelerometer accuracy is rarely
503
+ // meaningful for compass UX — ignore it. Both calibrated and
504
+ // uncalibrated mag report the same bucket semantics, so accept
505
+ // either.
506
+ if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD ||
507
+ sensor.type == Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED
508
+ ) {
509
+ handleMagAccuracyChanged(accuracy)
510
+ }
266
511
  }
267
512
  }
268
- sm.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME, handler)
269
-
270
- // Optional second subscription for magnetic-interference detection.
271
- // 5Hz is plenty (we only care about transitions in/out of the
272
- // Earth-field band) and keeps power cost negligible. Same listener
273
- // instance events are demuxed by sensor type in handleSensorEvent.
274
- sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)?.let { magSensor ->
275
- sm.registerListener(listener, magSensor, SensorManager.SENSOR_DELAY_NORMAL, handler)
513
+ sm.registerListener(listener, accel, SensorManager.SENSOR_DELAY_GAME, handler)
514
+ sm.registerListener(listener, mag, SensorManager.SENSOR_DELAY_GAME, handler)
515
+
516
+ // Game-RV is *optional*. It's gyro+accel only (no mag), so it's
517
+ // immune to magnetic interference and never bias-poisoned the way
518
+ // TYPE_ROTATION_VECTOR is. Old/cheap devices may lack a gyro
519
+ // entirely we silently fall back to pure mag+accel and behavior
520
+ // stays identical to the prior implementation.
521
+ val gameRv: Sensor? = sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
522
+ gameRv?.let {
523
+ sm.registerListener(listener, it, SensorManager.SENSOR_DELAY_GAME, handler)
276
524
  }
277
525
 
278
526
  sensorThread = thread
279
527
  sensorHandler = handler
280
- activeSensor = sensor
281
528
  activeListener = listener
282
529
  isSubscribed = true
283
530
 
531
+ hasAccel = false
532
+ hasMag = false
533
+ hasBias = false
534
+ lastBiasJumpNs = 0L
284
535
  smoothedSin = Double.NaN
285
536
  smoothedCos = Double.NaN
537
+ fusedYawDeg = Double.NaN
538
+ lastGameRvYawDeg = Double.NaN
539
+ lastGameRvTimeNs = 0L
540
+ lastYawRateDegPerS = 0.0
541
+ hasGameRv = false
542
+ seedFusedYaw = false
286
543
  lastEventNs = 0L
544
+ lastGameRvEventNs = 0L
287
545
  watchdogHandler.removeCallbacks(watchdogRunnable)
288
546
  watchdogHandler.postDelayed(watchdogRunnable, WATCHDOG_PERIOD_MS)
289
547
  }
@@ -294,7 +552,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
294
552
  sensorThread?.quitSafely()
295
553
  sensorThread = null
296
554
  sensorHandler = null
297
- activeSensor = null
298
555
  activeListener = null
299
556
  return
300
557
  }
@@ -302,7 +559,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
302
559
  val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
303
560
  activeListener?.let { sm?.unregisterListener(it) }
304
561
  activeListener = null
305
- activeSensor = null
306
562
  sensorHandler = null
307
563
  sensorThread?.quitSafely()
308
564
  sensorThread = null
@@ -312,10 +568,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
312
568
  private fun registerLifecycleCallbacks() {
313
569
  if (lifecycleCallbacks != null) return
314
570
  val app = NitroModules.applicationContext?.applicationContext as? Application ?: return
315
- // start() can be called from a headless / background context (e.g. a
316
- // headless JS task, or before any Activity has come up). Don't assume
317
- // foreground — query the OS so pauseOnBackground=true actually keeps
318
- // the sensor unsubscribed when the app isn't user-visible.
319
571
  activityCounter.set(if (isAppInForeground(app)) 1 else 0)
320
572
  val cb = object : Application.ActivityLifecycleCallbacks {
321
573
  override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
@@ -385,18 +637,181 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
385
637
  }
386
638
 
387
639
  private fun handleSensorEvent(event: SensorEvent) {
388
- val type = event.sensor.type
389
- if (type == Sensor.TYPE_MAGNETIC_FIELD) {
390
- handleMagneticEvent(event)
391
- return
392
- }
393
- if (type != Sensor.TYPE_ROTATION_VECTOR &&
394
- type != Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR
395
- ) return
640
+ when (event.sensor.type) {
641
+ Sensor.TYPE_ACCELEROMETER -> {
642
+ if (!event.values[0].isFinite() || !event.values[1].isFinite() ||
643
+ !event.values[2].isFinite()
644
+ ) return
645
+ if (!hasAccel) {
646
+ // Seed with the first sample to avoid an artificial ramp-up
647
+ // from zero, which would skew the orientation calc for
648
+ // ~1 second after subscription.
649
+ latestAccel[0] = event.values[0]
650
+ latestAccel[1] = event.values[1]
651
+ latestAccel[2] = event.values[2]
652
+ } else {
653
+ val a = adaptiveInputAlpha(INPUT_FILTER_ALPHA_ACCEL_STILL, INPUT_FILTER_ALPHA_ACCEL_FAST)
654
+ latestAccel[0] = a * event.values[0] + (1f - a) * latestAccel[0]
655
+ latestAccel[1] = a * event.values[1] + (1f - a) * latestAccel[1]
656
+ latestAccel[2] = a * event.values[2] + (1f - a) * latestAccel[2]
657
+ }
658
+ hasAccel = true
659
+ lastEventNs = SystemClock.elapsedRealtimeNanos()
660
+ }
661
+ Sensor.TYPE_MAGNETIC_FIELD, Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED -> {
662
+ // Uncalibrated mag delivers 6 floats: raw[0..2] and the OS's
663
+ // hard-iron bias estimate[3..5]. We apply the bias correction
664
+ // ourselves (mathematically equivalent to the calibrated path)
665
+ // and additionally use bias *jumps* as a separate interference
666
+ // signal — when the OS revises its bias, the field environment
667
+ // just changed (likely a magnet on/off), even if the corrected
668
+ // magnitude stays in the Earth band.
669
+ if (!event.values[0].isFinite() || !event.values[1].isFinite() ||
670
+ !event.values[2].isFinite()
671
+ ) return
672
+ val correctedX: Float
673
+ val correctedY: Float
674
+ val correctedZ: Float
675
+ if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED) {
676
+ val biasX = event.values[3]
677
+ val biasY = event.values[4]
678
+ val biasZ = event.values[5]
679
+ if (hasBias) {
680
+ if (abs(biasX - lastBias[0]) > BIAS_JUMP_UT ||
681
+ abs(biasY - lastBias[1]) > BIAS_JUMP_UT ||
682
+ abs(biasZ - lastBias[2]) > BIAS_JUMP_UT
683
+ ) {
684
+ lastBiasJumpNs = SystemClock.elapsedRealtimeNanos()
685
+ }
686
+ }
687
+ lastBias[0] = biasX
688
+ lastBias[1] = biasY
689
+ lastBias[2] = biasZ
690
+ hasBias = true
691
+ correctedX = event.values[0] - biasX
692
+ correctedY = event.values[1] - biasY
693
+ correctedZ = event.values[2] - biasZ
694
+ } else {
695
+ // Calibrated mag — the OS already subtracted its bias estimate.
696
+ correctedX = event.values[0]
697
+ correctedY = event.values[1]
698
+ correctedZ = event.values[2]
699
+ }
700
+
701
+ if (!hasMag) {
702
+ latestMag[0] = correctedX
703
+ latestMag[1] = correctedY
704
+ latestMag[2] = correctedZ
705
+ } else {
706
+ val a = adaptiveInputAlpha(INPUT_FILTER_ALPHA_MAG_STILL, INPUT_FILTER_ALPHA_MAG_FAST)
707
+ latestMag[0] = a * correctedX + (1f - a) * latestMag[0]
708
+ latestMag[1] = a * correctedY + (1f - a) * latestMag[1]
709
+ latestMag[2] = a * correctedZ + (1f - a) * latestMag[2]
710
+ }
711
+ hasMag = true
712
+ lastEventNs = SystemClock.elapsedRealtimeNanos()
713
+
714
+ // Use *raw* (unfiltered) corrected magnitude for the
715
+ // interference signal so it responds instantly to spikes. The
716
+ // LP-filtered vector is only used downstream for the heading
717
+ // calc. Interference is OR-combined with the bias-jump signal
718
+ // inside evaluateInterference.
719
+ val rx = correctedX.toDouble()
720
+ val ry = correctedY.toDouble()
721
+ val rz = correctedZ.toDouble()
722
+ val magnitude = sqrt(rx * rx + ry * ry + rz * rz)
723
+ lastFieldUt = magnitude
724
+ evaluateInterference(magnitude)
396
725
 
397
- lastEventNs = SystemClock.elapsedRealtimeNanos()
726
+ // Recompute heading on every mag event (the limiting-rate
727
+ // sensor). If we don't have an accelerometer reading yet, hold
728
+ // off — we need both for getRotationMatrix.
729
+ if (hasAccel) computeAndDeliverHeading()
730
+ }
731
+ Sensor.TYPE_GAME_ROTATION_VECTOR -> {
732
+ // Game-RV is gyro+accel only. We extract a *yaw delta* between
733
+ // events, integrate it into `fusedYawDeg`, and let mag samples
734
+ // pull `fusedYawDeg` toward absolute truth via the small blend
735
+ // in computeAndDeliverHeading. We track its freshness in a
736
+ // separate `lastGameRvEventNs` (not the shared `lastEventNs`)
737
+ // so the watchdog can detect a gyro-only freeze without being
738
+ // suppressed by accel/mag continuing to fire.
739
+ // Reject non-finite sensor values up-front. Some Samsung /
740
+ // MediaTek HALs occasionally emit a single NaN sample; without
741
+ // this guard, NaN would propagate through getRotationMatrixFromVector
742
+ // and permanently poison `fusedYawDeg` until recalibrate().
743
+ if (!event.values[0].isFinite() || !event.values[1].isFinite() ||
744
+ !event.values[2].isFinite() || !event.values[3].isFinite()
745
+ ) return
746
+ lastGameRvEventNs = SystemClock.elapsedRealtimeNanos()
747
+ SensorManager.getRotationMatrixFromVector(gameRvRotationMatrix, event.values)
748
+ val (axisX, axisY) = when (currentSurfaceRotation()) {
749
+ Surface.ROTATION_90 -> SensorManager.AXIS_Y to SensorManager.AXIS_MINUS_X
750
+ Surface.ROTATION_180 -> SensorManager.AXIS_MINUS_X to SensorManager.AXIS_MINUS_Y
751
+ Surface.ROTATION_270 -> SensorManager.AXIS_MINUS_Y to SensorManager.AXIS_X
752
+ else -> SensorManager.AXIS_X to SensorManager.AXIS_Y
753
+ }
754
+ // remapCoordinateSystem returns false for invalid axis pairs
755
+ // (the input matrix is left as zero-init garbage). Skip the
756
+ // sample on failure so getOrientation() doesn't read from a
757
+ // stale/zero matrix.
758
+ if (!SensorManager.remapCoordinateSystem(
759
+ gameRvRotationMatrix, axisX, axisY, gameRvRemappedMatrix
760
+ )
761
+ ) return
762
+ SensorManager.getOrientation(gameRvRemappedMatrix, gameRvOrientation)
763
+ var yawDeg = Math.toDegrees(gameRvOrientation[0].toDouble())
764
+ if (yawDeg.isNaN()) return
765
+ if (yawDeg < 0.0) yawDeg += 360.0
766
+
767
+ val nowNs = SystemClock.elapsedRealtimeNanos()
768
+ if (lastGameRvTimeNs > 0L && !lastGameRvYawDeg.isNaN()) {
769
+ // Wrap Δyaw to (-180, 180] so a yaw transition like
770
+ // 350°→10° produces +20° rather than -340°.
771
+ var dYaw = yawDeg - lastGameRvYawDeg
772
+ while (dYaw > 180.0) dYaw -= 360.0
773
+ while (dYaw < -180.0) dYaw += 360.0
774
+ val dtSec = (nowNs - lastGameRvTimeNs) / 1e9
775
+ if (dtSec > 0.001) {
776
+ // EMA the yaw rate so a single 100°/s spike on a still
777
+ // device can't transiently weaken the input low-pass and
778
+ // amplify steady-state jitter. α=0.3 → ~3-sample window.
779
+ val instantaneous = dYaw / dtSec
780
+ lastYawRateDegPerS = if (lastYawRateDegPerS == 0.0) {
781
+ instantaneous
782
+ } else {
783
+ 0.3 * instantaneous + 0.7 * lastYawRateDegPerS
784
+ }
785
+ }
786
+ if (!fusedYawDeg.isNaN()) {
787
+ // Integrate gyro-derived yaw rate into the fused estimate.
788
+ val next = wrap360(fusedYawDeg + dYaw)
789
+ // Defensive: if any prior arithmetic poisoned fusedYawDeg
790
+ // with NaN (e.g. via a downstream mixYawCircular that read
791
+ // NaN inputs), reseed from raw mag yaw on the next mag
792
+ // sample by leaving fusedYawDeg as NaN here.
793
+ fusedYawDeg = if (next.isFinite()) next else Double.NaN
794
+ }
795
+ }
796
+ lastGameRvYawDeg = yawDeg
797
+ lastGameRvTimeNs = nowNs
798
+ hasGameRv = true
799
+ }
800
+ else -> Unit
801
+ }
802
+ }
398
803
 
399
- SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
804
+ private fun computeAndDeliverHeading() {
805
+ val ok = SensorManager.getRotationMatrix(
806
+ rotationMatrix,
807
+ inclinationMatrix,
808
+ latestAccel,
809
+ latestMag
810
+ )
811
+ // getRotationMatrix returns false in degenerate cases (e.g. accel
812
+ // vector parallel to mag vector — extremely rare in practice).
813
+ // Skip the sample rather than emit garbage.
814
+ if (!ok) return
400
815
 
401
816
  val (axisX, axisY) = when (currentSurfaceRotation()) {
402
817
  Surface.ROTATION_90 -> SensorManager.AXIS_Y to SensorManager.AXIS_MINUS_X
@@ -404,53 +819,140 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
404
819
  Surface.ROTATION_270 -> SensorManager.AXIS_MINUS_Y to SensorManager.AXIS_X
405
820
  else -> SensorManager.AXIS_X to SensorManager.AXIS_Y
406
821
  }
407
- SensorManager.remapCoordinateSystem(rotationMatrix, axisX, axisY, remappedMatrix)
822
+ if (!SensorManager.remapCoordinateSystem(
823
+ rotationMatrix, axisX, axisY, remappedMatrix
824
+ )
825
+ ) return
408
826
  SensorManager.getOrientation(remappedMatrix, orientation)
409
827
 
410
- var heading = Math.toDegrees(orientation[0].toDouble())
411
- if (heading < 0.0) heading += 360.0
412
- heading = smoothHeading(heading)
828
+ var magYawDeg = Math.toDegrees(orientation[0].toDouble())
829
+ if (magYawDeg.isNaN()) return
830
+ if (magYawDeg < 0.0) magYawDeg += 360.0
413
831
 
414
- if (event.values.size > 4 && event.values[4] >= 0f) {
415
- val acc = Math.toDegrees(event.values[4].toDouble())
416
- hasPerSampleAccuracy = true
417
- lastAccuracyDeg = acc
418
- fireCalibration(qualityFor(acc))
832
+ // Drive the output from `fusedYawDeg` whenever we have a working
833
+ // gyro stream. Mag samples *correct* the gyro-integrated estimate
834
+ // toward absolute truth via a small complementary blend; during
835
+ // active interference we skip the correction entirely so a magnet
836
+ // can't pull fusion off-truth, and gyro alone carries heading
837
+ // until the field clears. When interference has *just* cleared,
838
+ // `seedFusedYaw` forces a one-shot snap so the user sees the new
839
+ // truth immediately instead of lagging through the ~1 s mag-blend
840
+ // time constant.
841
+ val headingDeg: Double = if (!hasGameRv) {
842
+ // No gyro available — fall back to the prior pure-mag path.
843
+ // Keep `fusedYawDeg` seeded for the moment game-RV starts firing.
844
+ fusedYawDeg = magYawDeg
845
+ magYawDeg
846
+ } else if (fusedYawDeg.isNaN() || !fusedYawDeg.isFinite() || seedFusedYaw) {
847
+ // Either we've never seeded fusion yet, or a prior arithmetic step
848
+ // poisoned it with NaN/Inf. Either way, snap to the current
849
+ // mag-derived truth so a single bad sample can't permanently
850
+ // freeze heading delivery.
851
+ seedFusedYaw = false
852
+ fusedYawDeg = magYawDeg
853
+ magYawDeg
854
+ } else if (lastInterference == true) {
855
+ // Trust gyro alone during interference — skip the mag mix.
856
+ fusedYawDeg
857
+ } else {
858
+ val mixed = mixYawCircular(fusedYawDeg, magYawDeg, MAG_CORRECTION_ALPHA)
859
+ fusedYawDeg = if (mixed.isFinite()) mixed else magYawDeg
860
+ fusedYawDeg
419
861
  }
420
862
 
863
+ val smoothed = smoothHeading(headingDeg)
864
+
421
865
  val prev = lastEmittedHeading
422
- val delta = if (prev.isNaN()) Double.MAX_VALUE else shortestArc(prev, heading)
866
+ val delta = if (prev.isNaN()) Double.MAX_VALUE else shortestArc(prev, smoothed)
423
867
  if (filterDeg > 0.0 && delta < filterDeg) return
424
- lastEmittedHeading = heading
868
+ lastEmittedHeading = smoothed
425
869
 
426
- var emitted = heading + declinationDeg
870
+ var emitted = smoothed + declinationDeg
427
871
  emitted = ((emitted % 360.0) + 360.0) % 360.0
428
- val sample = CompassSample(emitted, lastAccuracyDeg)
872
+ val sample = CompassSample(emitted, lastAccuracyDeg, lastFieldUt)
429
873
  lastSample = sample
430
874
  onHeading?.invoke(sample)
431
875
  }
432
876
 
433
- private fun handleMagneticEvent(event: SensorEvent) {
434
- if (event.values.size < 3) return
435
- val x = event.values[0]
436
- val y = event.values[1]
437
- val z = event.values[2]
438
- val magnitude = sqrt((x * x + y * y + z * z).toDouble())
439
- val isInterference = magnitude < EARTH_FIELD_MIN_UT || magnitude > EARTH_FIELD_MAX_UT
440
- if (lastInterference == isInterference) return
877
+ private fun evaluateInterference(magnitude: Double) {
878
+ // When a user location has been provided via setLocation(), the
879
+ // interference band is centered on the WMM-derived expected field
880
+ // strength with a tight ±LOCATION_FIELD_TOLERANCE_UT margin —
881
+ // catches weak interference the generic 20–70 µT band misses.
882
+ // Without a location, we fall back to the generic band that
883
+ // covers Earth's full latitude range.
884
+ val (minUt, maxUt) = if (expectedFieldUt > 0.0) {
885
+ Pair(
886
+ expectedFieldUt - LOCATION_FIELD_TOLERANCE_UT,
887
+ expectedFieldUt + LOCATION_FIELD_TOLERANCE_UT
888
+ )
889
+ } else {
890
+ Pair(EARTH_FIELD_MIN_UT, EARTH_FIELD_MAX_UT)
891
+ }
892
+ val magnitudeOutOfBand = magnitude < minUt || magnitude > maxUt
893
+ // A recent OS bias jump (only available on the uncalibrated mag
894
+ // path) means the field environment shifted enough for the OS to
895
+ // revise its hard-iron estimate. That's a reliable signal of a
896
+ // magnet-on/off event even when the corrected magnitude stays in
897
+ // the Earth band — common when one phone is placed on top of
898
+ // another. We hold this gate true for BIAS_JUMP_GRACE_NS after
899
+ // the most recent jump, then let it fall back to magnitude alone.
900
+ val biasJumpRecent = lastBiasJumpNs > 0L &&
901
+ (SystemClock.elapsedRealtimeNanos() - lastBiasJumpNs) < BIAS_JUMP_GRACE_NS
902
+ val isInterference = magnitudeOutOfBand || biasJumpRecent
903
+ val previous = lastInterference
904
+ if (previous == isInterference) return
441
905
  lastInterference = isInterference
442
906
  interferenceCb?.invoke(isInterference)
443
- // External interference makes the heading less trustworthy even when
444
- // the OS rotation-vector accuracy hasn't downgraded (gyro+accel can
445
- // keep its bucket high while the magnetometer is being skewed). Pump
446
- // the last raw quality back through fireCalibration so the
447
- // interference-aware downgrade is applied, and refresh the synthetic
448
- // degree value to match.
907
+
908
+ if (previous != true && isInterference) {
909
+ // Interference just started snapshot the raw quality so we
910
+ // can restore it when the field clears. The OS will likely
911
+ // downgrade the bucket while the magnet is present; that
912
+ // downgrade reflects the *symptom*, not a real change in
913
+ // calibration state.
914
+ preInterferenceRawQuality = lastRawQuality
915
+ }
916
+
917
+ // When interference clears, the EMA on (sin θ, cos θ) is still
918
+ // weighted with magnet-influenced samples from the last few
919
+ // hundred ms. Resetting it forces the next emission to snap to the
920
+ // current (post-magnet) truth instead of dragging the bad average
921
+ // forward. Same for the input LP — a magnet pulls the magnetometer
922
+ // far enough that the LP needs many samples to forget it.
923
+ //
924
+ // `seedFusedYaw` makes the next mag-derived yaw replace the
925
+ // gyro-integrated `fusedYawDeg` outright, instead of correcting
926
+ // it via the slow ~1 s complementary blend. Without this snap the
927
+ // user sees a visible lag while the blend pulls fusion toward the
928
+ // post-magnet truth.
929
+ //
930
+ // We also restore the pre-interference quality bucket. Without this,
931
+ // the OS's lazy refresh leaves the bucket at UNRELIABLE long after
932
+ // the field is back in the Earth band, so the calibration banner
933
+ // hangs even though the heading is correct. If we have no snapshot
934
+ // (interference fired before the OS ever reported a bucket), we
935
+ // default to MEDIUM — a usable heading that doesn't trigger the
936
+ // calibration banner. The OS will downgrade us via
937
+ // onAccuracyChanged if it actually disagrees.
938
+ if (previous == true && !isInterference) {
939
+ smoothedSin = Double.NaN
940
+ smoothedCos = Double.NaN
941
+ hasMag = false
942
+ lastEmittedHeading = Double.NaN
943
+ seedFusedYaw = true
944
+ lastRawQuality = preInterferenceRawQuality ?: AccuracyQuality.MEDIUM
945
+ preInterferenceRawQuality = null
946
+ }
947
+
948
+ // Pump the (possibly restored) raw quality back through
949
+ // fireCalibration so the interference-aware downgrade is applied
950
+ // on the way in, and the post-clear restore propagates out.
449
951
  lastRawQuality?.let { fireCalibration(it) }
450
- if (!hasPerSampleAccuracy) refreshSyntheticAccuracy()
952
+ refreshSyntheticAccuracy()
451
953
  }
452
954
 
453
- private fun handleAccuracyChanged(accuracy: Int) {
955
+ private fun handleMagAccuracyChanged(accuracy: Int) {
454
956
  val quality = when (accuracy) {
455
957
  SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> AccuracyQuality.HIGH
456
958
  SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> AccuracyQuality.MEDIUM
@@ -458,7 +960,7 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
458
960
  else -> AccuracyQuality.UNRELIABLE
459
961
  }
460
962
  fireCalibration(quality)
461
- if (!hasPerSampleAccuracy) refreshSyntheticAccuracy()
963
+ refreshSyntheticAccuracy()
462
964
  }
463
965
 
464
966
  private fun smoothHeading(degrees: Double): Double {
@@ -487,16 +989,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
487
989
  return deg
488
990
  }
489
991
 
490
- private fun qualityFor(accuracyDeg: Double): AccuracyQuality {
491
- return when {
492
- accuracyDeg < 0 -> AccuracyQuality.UNRELIABLE
493
- accuracyDeg < 5 -> AccuracyQuality.HIGH
494
- accuracyDeg < 15 -> AccuracyQuality.MEDIUM
495
- accuracyDeg < 30 -> AccuracyQuality.LOW
496
- else -> AccuracyQuality.UNRELIABLE
497
- }
498
- }
499
-
500
992
  private fun degreesFor(quality: AccuracyQuality): Double = when (quality) {
501
993
  AccuracyQuality.HIGH -> 5.0
502
994
  AccuracyQuality.MEDIUM -> 15.0
@@ -504,12 +996,12 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
504
996
  AccuracyQuality.UNRELIABLE -> -1.0
505
997
  }
506
998
 
507
- // Magnetic interference is a separate signal from rotation-vector
508
- // accuracy on Android gyro+accel can keep the OS bucket "HIGH" even
509
- // while the magnetometer is being skewed by a laptop / car / steel.
510
- // Reporting `quality=high` while `interfering=true` is contradictory
511
- // UX, so we downgrade the surfaced bucket by one notch when
512
- // interference is currently detected.
999
+ // Magnetic interference is a separate signal from magnetometer
1000
+ // calibration — accel can keep its bucket "HIGH" even while the field
1001
+ // is being skewed by external metal/electronics. Reporting
1002
+ // `quality=high` while `interfering=true` is contradictory UX, so we
1003
+ // downgrade the surfaced bucket by one notch when interference is
1004
+ // currently detected.
513
1005
  private fun effectiveQuality(raw: AccuracyQuality): AccuracyQuality {
514
1006
  if (lastInterference != true) return raw
515
1007
  return when (raw) {
@@ -520,10 +1012,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
520
1012
  }
521
1013
  }
522
1014
 
523
- // For devices that don't publish a per-sample accuracy in
524
- // event.values[4], the only accuracy signal is the OS bucket. Map the
525
- // current effective bucket back to a representative degree value so
526
- // CompassSample.accuracy reflects interference too.
527
1015
  private fun refreshSyntheticAccuracy() {
528
1016
  val raw = lastRawQuality ?: return
529
1017
  lastAccuracyDeg = degreesFor(effectiveQuality(raw))
@@ -541,10 +1029,7 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
541
1029
  // Prefer the *activity's* display when available — on foldables and
542
1030
  // multi-window setups the activity's display can differ from the
543
1031
  // primary display, so reading via DisplayManager.DEFAULT_DISPLAY
544
- // gives the wrong rotation. Activity.getDisplay() is API 30+;
545
- // fall back to the deprecated WindowManager.defaultDisplay path on
546
- // older devices, and to DisplayManager when we have no activity
547
- // (early in the process before any lifecycle callback has fired).
1032
+ // gives the wrong rotation.
548
1033
  val activity = currentActivityRef?.get()
549
1034
  if (activity != null) {
550
1035
  val display: Display? = try {
@@ -570,4 +1055,40 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
570
1055
  val diff = ((to - from + 540.0) % 360.0) - 180.0
571
1056
  return abs(diff)
572
1057
  }
1058
+
1059
+ // Wrap a degree value into [0, 360).
1060
+ private fun wrap360(deg: Double): Double {
1061
+ val m = deg % 360.0
1062
+ return if (m < 0.0) m + 360.0 else m
1063
+ }
1064
+
1065
+ // Linear-interpolate `a` towards `b` on the circle by `t ∈ [0, 1]`.
1066
+ // Operates on (sin, cos) so it correctly handles the 0/360°
1067
+ // wraparound (e.g. blending 350° and 10° produces 0°, not 180°).
1068
+ // Equivalent to a 2-sample SLERP at this precision.
1069
+ private fun mixYawCircular(a: Double, b: Double, t: Double): Double {
1070
+ val ar = Math.toRadians(a)
1071
+ val br = Math.toRadians(b)
1072
+ val sa = Math.sin(ar); val ca = Math.cos(ar)
1073
+ val sb = Math.sin(br); val cb = Math.cos(br)
1074
+ val s = (1.0 - t) * sa + t * sb
1075
+ val c = (1.0 - t) * ca + t * cb
1076
+ var deg = Math.toDegrees(Math.atan2(s, c))
1077
+ if (deg < 0.0) deg += 360.0
1078
+ return deg
1079
+ }
1080
+
1081
+ // Linearly interpolate the input low-pass α between `still` (slow
1082
+ // smoothing) and `fast` (light smoothing) based on the gyro-derived
1083
+ // yaw rate. Stationary device → strong filter (kills jitter); rapid
1084
+ // turn → weak filter (avoids lag). Falls back to the `still` value
1085
+ // when the gyro hasn't reported yet.
1086
+ private fun adaptiveInputAlpha(still: Float, fast: Float): Float {
1087
+ if (!hasGameRv) return still
1088
+ val rate = abs(lastYawRateDegPerS)
1089
+ if (rate <= YAW_RATE_STILL_DEG_S) return still
1090
+ if (rate >= YAW_RATE_FAST_DEG_S) return fast
1091
+ val t = ((rate - YAW_RATE_STILL_DEG_S) / (YAW_RATE_FAST_DEG_S - YAW_RATE_STILL_DEG_S)).toFloat()
1092
+ return still + t * (fast - still)
1093
+ }
573
1094
  }