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
@@ -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
 
@@ -56,11 +62,75 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
56
62
  // structural steel routinely push readings well above 100 µT.
57
63
  private const val EARTH_FIELD_MIN_UT = 20.0
58
64
  private const val EARTH_FIELD_MAX_UT = 70.0
65
+
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().
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
59
128
  }
60
129
 
61
130
  @Volatile private var filterDeg: Double = 1.0
62
131
  @Volatile private var lastEmittedHeading: Double = Double.NaN
63
132
  @Volatile private var lastAccuracyDeg: Double = -1.0
133
+ @Volatile private var lastFieldUt: Double = -1.0
64
134
  @Volatile private var lastSample: CompassSample? = null
65
135
  @Volatile private var lastQuality: AccuracyQuality? = null
66
136
  @Volatile private var declinationDeg: Double = 0.0
@@ -68,44 +138,127 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
68
138
  @Volatile private var started: Boolean = false
69
139
  @Volatile private var isSubscribed: Boolean = false
70
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
71
146
  @Volatile private var lastInterference: Boolean? = null
72
147
  @Volatile private var currentActivityRef: WeakReference<Activity>? = null
148
+ @Volatile private var smoothedSin: Double = Double.NaN
149
+ @Volatile private var smoothedCos: Double = Double.NaN
150
+ @Volatile private var smoothingAlpha: Double = DEFAULT_SMOOTHING_ALPHA
151
+ // Last raw quality from the OS, before the interference downgrade is
152
+ // applied. Used to re-derive `lastQuality` when interference toggles.
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
73
163
 
74
- private val rotationMatrix = FloatArray(16)
75
- private val remappedMatrix = FloatArray(16)
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
175
+
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)
76
197
  private val orientation = FloatArray(3)
77
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
+
78
226
  private val epoch = AtomicInteger(0)
79
227
  private val activityCounter = AtomicInteger(0)
80
228
  private var sensorThread: HandlerThread? = null
81
229
  private var sensorHandler: Handler? = null
82
- private var activeSensor: Sensor? = null
83
230
  private var activeListener: SensorEventListener? = null
84
231
  private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
85
- private var onHeading: ((CompassSample) -> Unit)? = null
86
- private var calibrationCb: ((AccuracyQuality) -> Unit)? = null
87
- 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
88
239
 
89
240
  private val watchdogHandler = Handler(Looper.getMainLooper())
90
241
  private val watchdogRunnable = object : Runnable {
91
242
  override fun run() {
92
243
  // When the app is backgrounded, the OS legitimately suspends or
93
244
  // throttles non-wake-up sensors (Doze on API 23+, background limits
94
- // on API 26+). Re-registering the listener won't change that — it
95
- // just burns power flapping every 1.5s. Skip the staleness check
96
- // 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.
97
247
  val backgrounded = activityCounter.get() == 0
98
- val last = lastEventNs
99
248
  val now = SystemClock.elapsedRealtimeNanos()
100
- 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)) {
101
256
  synchronized(this@HybridNitroCompass) {
102
257
  if (started && isSubscribed) {
103
258
  unsubscribeLocked()
104
259
  subscribeLocked()
105
- // subscribeLocked() re-arms the watchdog itself, so don't
106
- // double-post below. Reset the timestamp to give the fresh
107
- // subscription a full window before being judged stale.
108
260
  lastEventNs = SystemClock.elapsedRealtimeNanos()
261
+ lastGameRvEventNs = 0L
109
262
  }
110
263
  }
111
264
  return
@@ -124,12 +277,17 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
124
277
  synchronized(this) {
125
278
  stopLocked()
126
279
  started = true
127
- 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
128
284
  this.onHeading = onHeading
129
285
  lastEmittedHeading = Double.NaN
130
286
  lastAccuracyDeg = -1.0
287
+ lastFieldUt = -1.0
131
288
  lastSample = null
132
289
  lastQuality = null
290
+ lastRawQuality = null
133
291
 
134
292
  registerLifecycleCallbacks()
135
293
  subscribeLocked()
@@ -145,34 +303,87 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
145
303
  override fun hasCompass(): Boolean {
146
304
  val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
147
305
  ?: return false
148
- return sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null ||
149
- 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
150
309
  }
151
310
 
152
311
  override fun isStarted(): Boolean = started
153
312
 
154
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
155
318
  filterDeg = degrees.coerceAtLeast(0.0)
156
319
  }
157
320
 
321
+ override fun setSmoothing(alpha: Double) {
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)
328
+ }
329
+
158
330
  override fun getDiagnostics(): SensorDiagnostics? {
159
331
  val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
160
332
  ?: return null
161
- return when {
162
- sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null ->
163
- SensorDiagnostics(SensorKind.ROTATIONVECTOR)
164
- sm.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR) != null ->
165
- SensorDiagnostics(SensorKind.GEOMAGNETICROTATIONVECTOR)
166
- 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
167
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
+ )
168
356
  }
169
357
 
170
358
  override fun getCurrentHeading(): CompassSample? = lastSample
171
359
 
172
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
173
364
  declinationDeg = degrees
174
365
  }
175
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
+
176
387
  override fun setOnCalibrationNeeded(onChange: (quality: AccuracyQuality) -> Unit) {
177
388
  calibrationCb = onChange
178
389
  }
@@ -196,6 +407,46 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
196
407
  }
197
408
  }
198
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
+
199
450
  // Sensors don't require a runtime permission on Android, so both
200
451
  // permission methods are unconditionally granted.
201
452
  override fun getPermissionStatus(): PermissionStatus = PermissionStatus.GRANTED
@@ -214,15 +465,27 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
214
465
  lastSample = null
215
466
  lastQuality = null
216
467
  lastInterference = null
468
+ preInterferenceRawQuality = null
217
469
  }
218
470
 
219
471
  private fun subscribeLocked() {
220
472
  if (isSubscribed) return
221
473
  val sm = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
222
474
  ?: throw IllegalStateException("SensorManager unavailable")
223
- val sensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
224
- ?: sm.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR)
225
- ?: 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
226
489
 
227
490
  val myEpoch = epoch.incrementAndGet()
228
491
  val thread = HandlerThread("NitroCompass-Sensor").also { it.start() }
@@ -235,26 +498,50 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
235
498
 
236
499
  override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
237
500
  if (myEpoch != epoch.get()) return
238
- 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
+ }
239
511
  }
240
512
  }
241
- sm.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME, handler)
242
-
243
- // Optional second subscription for magnetic-interference detection.
244
- // 5Hz is plenty (we only care about transitions in/out of the
245
- // Earth-field band) and keeps power cost negligible. Same listener
246
- // instance events are demuxed by sensor type in handleSensorEvent.
247
- sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)?.let { magSensor ->
248
- 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)
249
524
  }
250
525
 
251
526
  sensorThread = thread
252
527
  sensorHandler = handler
253
- activeSensor = sensor
254
528
  activeListener = listener
255
529
  isSubscribed = true
256
530
 
531
+ hasAccel = false
532
+ hasMag = false
533
+ hasBias = false
534
+ lastBiasJumpNs = 0L
535
+ smoothedSin = Double.NaN
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
257
543
  lastEventNs = 0L
544
+ lastGameRvEventNs = 0L
258
545
  watchdogHandler.removeCallbacks(watchdogRunnable)
259
546
  watchdogHandler.postDelayed(watchdogRunnable, WATCHDOG_PERIOD_MS)
260
547
  }
@@ -265,7 +552,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
265
552
  sensorThread?.quitSafely()
266
553
  sensorThread = null
267
554
  sensorHandler = null
268
- activeSensor = null
269
555
  activeListener = null
270
556
  return
271
557
  }
@@ -273,7 +559,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
273
559
  val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
274
560
  activeListener?.let { sm?.unregisterListener(it) }
275
561
  activeListener = null
276
- activeSensor = null
277
562
  sensorHandler = null
278
563
  sensorThread?.quitSafely()
279
564
  sensorThread = null
@@ -283,10 +568,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
283
568
  private fun registerLifecycleCallbacks() {
284
569
  if (lifecycleCallbacks != null) return
285
570
  val app = NitroModules.applicationContext?.applicationContext as? Application ?: return
286
- // start() can be called from a headless / background context (e.g. a
287
- // headless JS task, or before any Activity has come up). Don't assume
288
- // foreground — query the OS so pauseOnBackground=true actually keeps
289
- // the sensor unsubscribed when the app isn't user-visible.
290
571
  activityCounter.set(if (isAppInForeground(app)) 1 else 0)
291
572
  val cb = object : Application.ActivityLifecycleCallbacks {
292
573
  override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
@@ -356,18 +637,181 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
356
637
  }
357
638
 
358
639
  private fun handleSensorEvent(event: SensorEvent) {
359
- val type = event.sensor.type
360
- if (type == Sensor.TYPE_MAGNETIC_FIELD) {
361
- handleMagneticEvent(event)
362
- return
363
- }
364
- if (type != Sensor.TYPE_ROTATION_VECTOR &&
365
- type != Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR
366
- ) 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()
367
713
 
368
- lastEventNs = SystemClock.elapsedRealtimeNanos()
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)
369
725
 
370
- SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
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
+ }
803
+
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
371
815
 
372
816
  val (axisX, axisY) = when (currentSurfaceRotation()) {
373
817
  Surface.ROTATION_90 -> SensorManager.AXIS_Y to SensorManager.AXIS_MINUS_X
@@ -375,84 +819,217 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
375
819
  Surface.ROTATION_270 -> SensorManager.AXIS_MINUS_Y to SensorManager.AXIS_X
376
820
  else -> SensorManager.AXIS_X to SensorManager.AXIS_Y
377
821
  }
378
- SensorManager.remapCoordinateSystem(rotationMatrix, axisX, axisY, remappedMatrix)
822
+ if (!SensorManager.remapCoordinateSystem(
823
+ rotationMatrix, axisX, axisY, remappedMatrix
824
+ )
825
+ ) return
379
826
  SensorManager.getOrientation(remappedMatrix, orientation)
380
827
 
381
- var heading = Math.toDegrees(orientation[0].toDouble())
382
- if (heading < 0.0) heading += 360.0
828
+ var magYawDeg = Math.toDegrees(orientation[0].toDouble())
829
+ if (magYawDeg.isNaN()) return
830
+ if (magYawDeg < 0.0) magYawDeg += 360.0
383
831
 
384
- if (event.values.size > 4 && event.values[4] >= 0f) {
385
- val acc = Math.toDegrees(event.values[4].toDouble())
386
- lastAccuracyDeg = acc
387
- 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
388
861
  }
389
862
 
863
+ val smoothed = smoothHeading(headingDeg)
864
+
390
865
  val prev = lastEmittedHeading
391
- 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)
392
867
  if (filterDeg > 0.0 && delta < filterDeg) return
393
- lastEmittedHeading = heading
868
+ lastEmittedHeading = smoothed
394
869
 
395
- var emitted = heading + declinationDeg
870
+ var emitted = smoothed + declinationDeg
396
871
  emitted = ((emitted % 360.0) + 360.0) % 360.0
397
- val sample = CompassSample(emitted, lastAccuracyDeg)
872
+ val sample = CompassSample(emitted, lastAccuracyDeg, lastFieldUt)
398
873
  lastSample = sample
399
874
  onHeading?.invoke(sample)
400
875
  }
401
876
 
402
- private fun handleMagneticEvent(event: SensorEvent) {
403
- if (event.values.size < 3) return
404
- val x = event.values[0]
405
- val y = event.values[1]
406
- val z = event.values[2]
407
- val magnitude = sqrt((x * x + y * y + z * z).toDouble())
408
- val isInterference = magnitude < EARTH_FIELD_MIN_UT || magnitude > EARTH_FIELD_MAX_UT
409
- 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
410
905
  lastInterference = isInterference
411
906
  interferenceCb?.invoke(isInterference)
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.
951
+ lastRawQuality?.let { fireCalibration(it) }
952
+ refreshSyntheticAccuracy()
412
953
  }
413
954
 
414
- private fun handleAccuracyChanged(accuracy: Int) {
955
+ private fun handleMagAccuracyChanged(accuracy: Int) {
415
956
  val quality = when (accuracy) {
416
957
  SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> AccuracyQuality.HIGH
417
958
  SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> AccuracyQuality.MEDIUM
418
959
  SensorManager.SENSOR_STATUS_ACCURACY_LOW -> AccuracyQuality.LOW
419
960
  else -> AccuracyQuality.UNRELIABLE
420
961
  }
421
- if (lastAccuracyDeg < 0.0) {
422
- lastAccuracyDeg = when (quality) {
423
- AccuracyQuality.HIGH -> 5.0
424
- AccuracyQuality.MEDIUM -> 15.0
425
- AccuracyQuality.LOW -> 30.0
426
- AccuracyQuality.UNRELIABLE -> -1.0
427
- }
428
- }
429
962
  fireCalibration(quality)
963
+ refreshSyntheticAccuracy()
430
964
  }
431
965
 
432
- private fun qualityFor(accuracyDeg: Double): AccuracyQuality {
433
- return when {
434
- accuracyDeg < 0 -> AccuracyQuality.UNRELIABLE
435
- accuracyDeg < 5 -> AccuracyQuality.HIGH
436
- accuracyDeg < 15 -> AccuracyQuality.MEDIUM
437
- accuracyDeg < 30 -> AccuracyQuality.LOW
438
- else -> AccuracyQuality.UNRELIABLE
966
+ private fun smoothHeading(degrees: Double): Double {
967
+ val rad = Math.toRadians(degrees)
968
+ val s = Math.sin(rad)
969
+ val c = Math.cos(rad)
970
+ val ss = smoothedSin
971
+ val cs = smoothedCos
972
+ if (ss.isNaN() || cs.isNaN()) {
973
+ smoothedSin = s
974
+ smoothedCos = c
975
+ return degrees
439
976
  }
977
+ val a = smoothingAlpha
978
+ if (a >= 1.0) {
979
+ smoothedSin = s
980
+ smoothedCos = c
981
+ return degrees
982
+ }
983
+ val newSin = a * s + (1.0 - a) * ss
984
+ val newCos = a * c + (1.0 - a) * cs
985
+ smoothedSin = newSin
986
+ smoothedCos = newCos
987
+ var deg = Math.toDegrees(Math.atan2(newSin, newCos))
988
+ if (deg < 0.0) deg += 360.0
989
+ return deg
990
+ }
991
+
992
+ private fun degreesFor(quality: AccuracyQuality): Double = when (quality) {
993
+ AccuracyQuality.HIGH -> 5.0
994
+ AccuracyQuality.MEDIUM -> 15.0
995
+ AccuracyQuality.LOW -> 30.0
996
+ AccuracyQuality.UNRELIABLE -> -1.0
997
+ }
998
+
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.
1005
+ private fun effectiveQuality(raw: AccuracyQuality): AccuracyQuality {
1006
+ if (lastInterference != true) return raw
1007
+ return when (raw) {
1008
+ AccuracyQuality.HIGH -> AccuracyQuality.MEDIUM
1009
+ AccuracyQuality.MEDIUM -> AccuracyQuality.LOW
1010
+ AccuracyQuality.LOW -> AccuracyQuality.UNRELIABLE
1011
+ AccuracyQuality.UNRELIABLE -> AccuracyQuality.UNRELIABLE
1012
+ }
1013
+ }
1014
+
1015
+ private fun refreshSyntheticAccuracy() {
1016
+ val raw = lastRawQuality ?: return
1017
+ lastAccuracyDeg = degreesFor(effectiveQuality(raw))
440
1018
  }
441
1019
 
442
1020
  private fun fireCalibration(quality: AccuracyQuality) {
443
- if (quality == lastQuality) return
444
- lastQuality = quality
445
- calibrationCb?.invoke(quality)
1021
+ lastRawQuality = quality
1022
+ val effective = effectiveQuality(quality)
1023
+ if (effective == lastQuality) return
1024
+ lastQuality = effective
1025
+ calibrationCb?.invoke(effective)
446
1026
  }
447
1027
 
448
1028
  private fun currentSurfaceRotation(): Int {
449
1029
  // Prefer the *activity's* display when available — on foldables and
450
1030
  // multi-window setups the activity's display can differ from the
451
1031
  // primary display, so reading via DisplayManager.DEFAULT_DISPLAY
452
- // gives the wrong rotation. Activity.getDisplay() is API 30+;
453
- // fall back to the deprecated WindowManager.defaultDisplay path on
454
- // older devices, and to DisplayManager when we have no activity
455
- // (early in the process before any lifecycle callback has fired).
1032
+ // gives the wrong rotation.
456
1033
  val activity = currentActivityRef?.get()
457
1034
  if (activity != null) {
458
1035
  val display: Display? = try {
@@ -478,4 +1055,40 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
478
1055
  val diff = ((to - from + 540.0) % 360.0) - 180.0
479
1056
  return abs(diff)
480
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
+ }
481
1094
  }