@mentra/crust 0.1.0-dev.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 (66) hide show
  1. package/README.md +35 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +19 -0
  6. package/android/src/main/java/com/mentra/crust/CrustModule.kt +882 -0
  7. package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +246 -0
  11. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
  13. package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
  14. package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
  15. package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
  16. package/android/src/main/res/values/strings.xml +3 -0
  17. package/app.plugin.js +3 -0
  18. package/build/Crust.types.d.ts +148 -0
  19. package/build/Crust.types.d.ts.map +1 -0
  20. package/build/Crust.types.js +2 -0
  21. package/build/Crust.types.js.map +1 -0
  22. package/build/CrustModule.d.ts +175 -0
  23. package/build/CrustModule.d.ts.map +1 -0
  24. package/build/CrustModule.js +4 -0
  25. package/build/CrustModule.js.map +1 -0
  26. package/build/CrustModule.web.d.ts +26 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +54 -0
  29. package/build/CrustModule.web.js.map +1 -0
  30. package/build/CrustView.d.ts +4 -0
  31. package/build/CrustView.d.ts.map +1 -0
  32. package/build/CrustView.js +7 -0
  33. package/build/CrustView.js.map +1 -0
  34. package/build/CrustView.web.d.ts +4 -0
  35. package/build/CrustView.web.d.ts.map +1 -0
  36. package/build/CrustView.web.js +7 -0
  37. package/build/CrustView.web.js.map +1 -0
  38. package/build/index.d.ts +4 -0
  39. package/build/index.d.ts.map +1 -0
  40. package/build/index.js +6 -0
  41. package/build/index.js.map +1 -0
  42. package/expo-module.config.json +9 -0
  43. package/ios/Crust.podspec +65 -0
  44. package/ios/CrustModule.swift +544 -0
  45. package/ios/CrustView.swift +38 -0
  46. package/ios/Resources/startup.js +814 -0
  47. package/ios/Source/JSCDispatcher.swift +226 -0
  48. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  49. package/ios/Source/JSCRuntime.swift +673 -0
  50. package/ios/Source/utils/ImageProcessor.swift +392 -0
  51. package/ios/Source/utils/SystemGestures.swift +53 -0
  52. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  53. package/ios/heading/HeadingManager.swift +74 -0
  54. package/ios/navigation/NavPayloads.swift +62 -0
  55. package/ios/navigation/NavigationManager.swift +720 -0
  56. package/package.json +69 -0
  57. package/plugin/build/index.d.ts +19 -0
  58. package/plugin/build/index.js +23 -0
  59. package/plugin/build/withAndroid.d.ts +2 -0
  60. package/plugin/build/withAndroid.js +78 -0
  61. package/src/Crust.types.ts +157 -0
  62. package/src/CrustModule.ts +186 -0
  63. package/src/CrustModule.web.ts +57 -0
  64. package/src/CrustView.tsx +10 -0
  65. package/src/CrustView.web.tsx +11 -0
  66. package/src/index.ts +5 -0
@@ -0,0 +1,374 @@
1
+ import AVFoundation
2
+ import CoreImage
3
+ import Foundation
4
+
5
+ /// Gyroscope-based video stabilizer.
6
+ /// Uses IMU sidecar data to apply motion-compensated frame warping,
7
+ /// correcting rotation jitter in videos.
8
+ ///
9
+ /// Phase 1: rotation correction (pan/tilt/roll) using gyro integration
10
+ /// + exponential moving average smoothing.
11
+ class VideoStabilizer {
12
+ private static let TAG = "VideoStabilizer"
13
+ private static let SMOOTH_FACTOR = 0.98
14
+ private static let SMOOTH_PASSES = 3
15
+ private static let CROP_MARGIN = 0.08
16
+
17
+ struct ImuSample {
18
+ let timeMs: Double
19
+ let ax: Double
20
+ let ay: Double
21
+ let az: Double
22
+ let gx: Double
23
+ let gy: Double
24
+ let gz: Double
25
+ }
26
+
27
+ /// Stabilize a video using IMU sidecar data.
28
+ /// - Parameters:
29
+ /// - inputPath: Path to the input MP4 video
30
+ /// - imuPath: Path to the IMU sidecar JSON file
31
+ /// - outputPath: Path to write the stabilized MP4
32
+ /// - Returns: Processing time in milliseconds, or -1 on failure
33
+ static func stabilize(inputPath: String, imuPath: String, outputPath: String) -> Int64 {
34
+ let startTime = CFAbsoluteTimeGetCurrent()
35
+
36
+ // Parse IMU data
37
+ guard let imuSamples = parseImuData(imuPath), !imuSamples.isEmpty else {
38
+ NSLog("\(TAG): No IMU data available")
39
+ return -1
40
+ }
41
+ NSLog("\(TAG): Loaded \(imuSamples.count) IMU samples")
42
+
43
+ // Integrate gyro data to get cumulative rotation (3 axes)
44
+ var cumulativeRoll = [Double](repeating: 0, count: imuSamples.count)
45
+ var cumulativePitch = [Double](repeating: 0, count: imuSamples.count)
46
+ var cumulativeYaw = [Double](repeating: 0, count: imuSamples.count)
47
+
48
+ for i in 1 ..< imuSamples.count {
49
+ var dt = (imuSamples[i].timeMs - imuSamples[i - 1].timeMs) / 1000.0
50
+ if dt <= 0 || dt > 0.1 { dt = 0.01 }
51
+
52
+ cumulativeRoll[i] = cumulativeRoll[i - 1] + imuSamples[i].gx * dt
53
+ cumulativePitch[i] = cumulativePitch[i - 1] + imuSamples[i].gy * dt
54
+ cumulativeYaw[i] = cumulativeYaw[i - 1] + imuSamples[i].gz * dt
55
+ }
56
+
57
+ // Smooth with multi-pass bidirectional EMA for aggressive stabilization
58
+ let smoothRoll = smoothEmaMultiPass(cumulativeRoll)
59
+ let smoothPitch = smoothEmaMultiPass(cumulativePitch)
60
+ let smoothYaw = smoothEmaMultiPass(cumulativeYaw)
61
+
62
+ // Correction = smooth - actual
63
+ var corrRoll = [Double](repeating: 0, count: imuSamples.count)
64
+ var corrPitch = [Double](repeating: 0, count: imuSamples.count)
65
+ var corrYaw = [Double](repeating: 0, count: imuSamples.count)
66
+ for i in 0 ..< imuSamples.count {
67
+ corrRoll[i] = smoothRoll[i] - cumulativeRoll[i]
68
+ corrPitch[i] = smoothPitch[i] - cumulativePitch[i]
69
+ corrYaw[i] = smoothYaw[i] - cumulativeYaw[i]
70
+ }
71
+
72
+ // Setup AVAsset reader/writer pipeline
73
+ let inputURL = URL(fileURLWithPath: inputPath)
74
+ let outputURL = URL(fileURLWithPath: outputPath)
75
+
76
+ // Remove output if it exists
77
+ try? FileManager.default.removeItem(at: outputURL)
78
+
79
+ let asset = AVAsset(url: inputURL)
80
+
81
+ guard let videoTrack = asset.tracks(withMediaType: .video).first else {
82
+ NSLog("\(TAG): No video track found")
83
+ return -1
84
+ }
85
+
86
+ let videoSize = videoTrack.naturalSize
87
+ let frameRate = videoTrack.nominalFrameRate
88
+ let videoDuration = CMTimeGetSeconds(asset.duration)
89
+ let imuDurationMs = imuSamples.last?.timeMs ?? 1.0
90
+
91
+ NSLog(
92
+ "\(TAG): Video \(Int(videoSize.width))x\(Int(videoSize.height)) fps=\(frameRate) duration=\(videoDuration)s"
93
+ )
94
+
95
+ // Setup reader
96
+ guard let reader = try? AVAssetReader(asset: asset) else {
97
+ NSLog("\(TAG): Failed to create AVAssetReader")
98
+ return -1
99
+ }
100
+
101
+ let readerSettings: [String: Any] = [
102
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
103
+ ]
104
+ let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: readerSettings)
105
+ reader.add(readerOutput)
106
+
107
+ // Setup writer
108
+ guard let writer = try? AVAssetWriter(outputURL: outputURL, fileType: .mp4) else {
109
+ NSLog("\(TAG): Failed to create AVAssetWriter")
110
+ return -1
111
+ }
112
+
113
+ let writerSettings: [String: Any] = [
114
+ AVVideoCodecKey: AVVideoCodecType.h264,
115
+ AVVideoWidthKey: Int(videoSize.width),
116
+ AVVideoHeightKey: Int(videoSize.height),
117
+ AVVideoCompressionPropertiesKey: [
118
+ AVVideoAverageBitRateKey: Int(videoTrack.estimatedDataRate),
119
+ AVVideoMaxKeyFrameIntervalKey: 30,
120
+ ],
121
+ ]
122
+ let videoFormatHint = videoTrack.formatDescriptions.first.map { $0 as! CMFormatDescription }
123
+ let writerInput = AVAssetWriterInput(
124
+ mediaType: .video, outputSettings: writerSettings, sourceFormatHint: videoFormatHint
125
+ )
126
+ writerInput.transform = videoTrack.preferredTransform
127
+
128
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor(
129
+ assetWriterInput: writerInput,
130
+ sourcePixelBufferAttributes: [
131
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
132
+ kCVPixelBufferWidthKey as String: Int(videoSize.width),
133
+ kCVPixelBufferHeightKey as String: Int(videoSize.height),
134
+ ]
135
+ )
136
+ writer.add(writerInput)
137
+
138
+ // Copy audio track if present
139
+ var audioWriterInput: AVAssetWriterInput?
140
+ var audioReaderOutput: AVAssetReaderTrackOutput?
141
+ if let audioTrack = asset.tracks(withMediaType: .audio).first {
142
+ let audioOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: nil)
143
+ reader.add(audioOutput)
144
+ audioReaderOutput = audioOutput
145
+
146
+ let audioFormatHint = audioTrack.formatDescriptions.first.map { $0 as! CMFormatDescription }
147
+ let audioInput = AVAssetWriterInput(
148
+ mediaType: .audio, outputSettings: nil, sourceFormatHint: audioFormatHint
149
+ )
150
+ writer.add(audioInput)
151
+ audioWriterInput = audioInput
152
+ }
153
+
154
+ // Start processing
155
+ reader.startReading()
156
+ writer.startWriting()
157
+ writer.startSession(atSourceTime: .zero)
158
+
159
+ let ciContext = CIContext(options: [.useSoftwareRenderer: false])
160
+ var frameCount = 0
161
+
162
+ // Process video frames
163
+ let videoGroup = DispatchGroup()
164
+ videoGroup.enter()
165
+
166
+ writerInput.requestMediaDataWhenReady(on: DispatchQueue(label: "videoStabilizer.video")) {
167
+ while writerInput.isReadyForMoreMediaData {
168
+ guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
169
+ writerInput.markAsFinished()
170
+ videoGroup.leave()
171
+ return
172
+ }
173
+
174
+ // autoreleasepool prevents CIImage/CIFilter intermediates from accumulating
175
+ // in the tight while loop, which otherwise causes OOM crashes on longer videos.
176
+ autoreleasepool {
177
+ let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
178
+ let frameTimeMs = CMTimeGetSeconds(presentationTime) * 1000.0
179
+
180
+ // Find correction for this frame
181
+ let ratio = imuDurationMs > 0 ? frameTimeMs / imuDurationMs : 0
182
+ let imuIdx = max(0, min(Int(ratio * Double(imuSamples.count - 1)), imuSamples.count - 1))
183
+
184
+ let rollCorr = corrRoll[imuIdx]
185
+ let pitchCorr = corrPitch[imuIdx]
186
+ let yawCorr = corrYaw[imuIdx]
187
+
188
+ guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
189
+ var ciImage = CIImage(cvPixelBuffer: pixelBuffer)
190
+
191
+ // Apply color corrections (same pipeline as ImageProcessor)
192
+ ciImage = applyToneCurve(ciImage)
193
+ ciImage = applyVibrance(ciImage)
194
+ ciImage = applyColorCorrection(ciImage)
195
+
196
+ // Always apply crop+scale for consistent framing, plus stabilization correction
197
+ let cx = videoSize.width / 2
198
+ let cy = videoSize.height / 2
199
+ let scale = 1.0 / (1.0 - 2.0 * CROP_MARGIN)
200
+
201
+ // Clamp corrections to the crop margin so we never show black edges
202
+ let maxShiftX = CROP_MARGIN * Double(videoSize.width)
203
+ let maxShiftY = CROP_MARGIN * Double(videoSize.height)
204
+ let maxRollRad = CROP_MARGIN * 0.5
205
+
206
+ let clampedRoll = min(max(rollCorr, -maxRollRad), maxRollRad)
207
+ let clampedPitchShift = min(max(-pitchCorr * Double(cx), -maxShiftX), maxShiftX)
208
+ let clampedYawShift = min(max(yawCorr * Double(cy), -maxShiftY), maxShiftY)
209
+
210
+ // Build transform: center → scale → rotate → translate → uncenter
211
+ let transform = CGAffineTransform.identity
212
+ .translatedBy(x: cx, y: cy)
213
+ .scaledBy(x: CGFloat(scale), y: CGFloat(scale))
214
+ .rotated(by: CGFloat(-clampedRoll))
215
+ .translatedBy(x: CGFloat(clampedPitchShift), y: CGFloat(clampedYawShift))
216
+ .translatedBy(x: -cx, y: -cy)
217
+
218
+ ciImage = ciImage.transformed(by: transform)
219
+
220
+ // Crop back to original size and reset origin to (0,0)
221
+ // Without the origin reset, CIContext.render offsets into the pixel buffer,
222
+ // leaving black strips on the right/bottom edges.
223
+ let cropRect = CGRect(
224
+ x: ciImage.extent.origin.x + (ciImage.extent.width - videoSize.width) / 2,
225
+ y: ciImage.extent.origin.y + (ciImage.extent.height - videoSize.height) / 2,
226
+ width: videoSize.width,
227
+ height: videoSize.height
228
+ )
229
+ ciImage = ciImage.cropped(to: cropRect)
230
+ .transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
231
+
232
+ // Render to pixel buffer
233
+ if let pool = adaptor.pixelBufferPool {
234
+ var outputBuffer: CVPixelBuffer?
235
+ CVPixelBufferPoolCreatePixelBuffer(nil, pool, &outputBuffer)
236
+ if let outBuf = outputBuffer {
237
+ ciContext.render(ciImage, to: outBuf)
238
+ adaptor.append(outBuf, withPresentationTime: presentationTime)
239
+ }
240
+ }
241
+
242
+ frameCount += 1
243
+ }
244
+ }
245
+ }
246
+
247
+ // Process audio
248
+ if let audioInput = audioWriterInput, let audioOutput = audioReaderOutput {
249
+ let audioGroup = DispatchGroup()
250
+ audioGroup.enter()
251
+
252
+ audioInput.requestMediaDataWhenReady(on: DispatchQueue(label: "videoStabilizer.audio")) {
253
+ while audioInput.isReadyForMoreMediaData {
254
+ guard let sampleBuffer = audioOutput.copyNextSampleBuffer() else {
255
+ audioInput.markAsFinished()
256
+ audioGroup.leave()
257
+ return
258
+ }
259
+ audioInput.append(sampleBuffer)
260
+ }
261
+ }
262
+ audioGroup.wait()
263
+ }
264
+
265
+ videoGroup.wait()
266
+
267
+ // Finish writing
268
+ let semaphore = DispatchSemaphore(value: 0)
269
+ writer.finishWriting {
270
+ semaphore.signal()
271
+ }
272
+ semaphore.wait()
273
+
274
+ let elapsed = Int64((CFAbsoluteTimeGetCurrent() - startTime) * 1000)
275
+ NSLog("\(TAG): Stabilization complete: \(frameCount) frames in \(elapsed)ms")
276
+ return elapsed
277
+ }
278
+
279
+ // MARK: - Color Pipeline Tuning Parameters
280
+
281
+ // Tone curve anchor points (CIToneCurve: x = input, y = output)
282
+ private static let toneCurvePoint0 = CIVector(x: 0.00, y: 0.05)
283
+ private static let toneCurvePoint1 = CIVector(x: 0.25, y: 0.22)
284
+ private static let toneCurvePoint2 = CIVector(x: 0.50, y: 0.50)
285
+ private static let toneCurvePoint3 = CIVector(x: 0.75, y: 0.78)
286
+ private static let toneCurvePoint4 = CIVector(x: 1.00, y: 0.95)
287
+
288
+ // Vibrance: selective saturation boost (0.0 = off, 1.0 = max)
289
+ private static let vibranceAmount: Double = 0.3
290
+
291
+ // Color correction matrix (CIColorMatrix vectors)
292
+ private static let rVector = CIVector(x: 1.06, y: 0.02, z: -0.01, w: 0)
293
+ private static let gVector = CIVector(x: 0.01, y: 1.04, z: -0.01, w: 0)
294
+ private static let bVector = CIVector(x: -0.02, y: 0.01, z: 1.02, w: 0)
295
+ private static let aVector = CIVector(x: 0, y: 0, z: 0, w: 1)
296
+ private static let biasVector = CIVector(x: 5.0 / 255.0, y: 3.0 / 255.0, z: 0, w: 0)
297
+
298
+ // MARK: - Color Processing
299
+
300
+ /// Apply S-curve tone mapping.
301
+ private static func applyToneCurve(_ image: CIImage) -> CIImage {
302
+ guard let filter = CIFilter(name: "CIToneCurve") else { return image }
303
+ filter.setValue(image, forKey: kCIInputImageKey)
304
+ filter.setValue(toneCurvePoint0, forKey: "inputPoint0")
305
+ filter.setValue(toneCurvePoint1, forKey: "inputPoint1")
306
+ filter.setValue(toneCurvePoint2, forKey: "inputPoint2")
307
+ filter.setValue(toneCurvePoint3, forKey: "inputPoint3")
308
+ filter.setValue(toneCurvePoint4, forKey: "inputPoint4")
309
+ return filter.outputImage ?? image
310
+ }
311
+
312
+ /// Apply vibrance — selectively boosts undersaturated colors.
313
+ private static func applyVibrance(_ image: CIImage) -> CIImage {
314
+ guard let filter = CIFilter(name: "CIVibrance") else { return image }
315
+ filter.setValue(image, forKey: kCIInputImageKey)
316
+ filter.setValue(vibranceAmount, forKey: "inputAmount")
317
+ return filter.outputImage ?? image
318
+ }
319
+
320
+ /// Apply color correction (warmth/white balance) via CIColorMatrix.
321
+ private static func applyColorCorrection(_ image: CIImage) -> CIImage {
322
+ guard let filter = CIFilter(name: "CIColorMatrix") else { return image }
323
+ filter.setValue(image, forKey: kCIInputImageKey)
324
+ filter.setValue(rVector, forKey: "inputRVector")
325
+ filter.setValue(gVector, forKey: "inputGVector")
326
+ filter.setValue(bVector, forKey: "inputBVector")
327
+ filter.setValue(aVector, forKey: "inputAVector")
328
+ filter.setValue(biasVector, forKey: "inputBiasVector")
329
+ return filter.outputImage ?? image
330
+ }
331
+
332
+ // MARK: - Private Helpers
333
+
334
+ private static func parseImuData(_ path: String) -> [ImuSample]? {
335
+ guard let data = FileManager.default.contents(atPath: path),
336
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
337
+ let samples = json["samples"] as? [[Any]]
338
+ else {
339
+ return nil
340
+ }
341
+
342
+ return samples.compactMap { s -> ImuSample? in
343
+ guard s.count >= 7 else { return nil }
344
+ return ImuSample(
345
+ timeMs: (s[0] as? NSNumber)?.doubleValue ?? 0,
346
+ ax: (s[1] as? NSNumber)?.doubleValue ?? 0,
347
+ ay: (s[2] as? NSNumber)?.doubleValue ?? 0,
348
+ az: (s[3] as? NSNumber)?.doubleValue ?? 0,
349
+ gx: (s[4] as? NSNumber)?.doubleValue ?? 0,
350
+ gy: (s[5] as? NSNumber)?.doubleValue ?? 0,
351
+ gz: (s[6] as? NSNumber)?.doubleValue ?? 0
352
+ )
353
+ }
354
+ }
355
+
356
+ private static func smoothEmaMultiPass(_ data: [Double]) -> [Double] {
357
+ guard !data.isEmpty else { return data }
358
+ var result = data
359
+ for _ in 0 ..< SMOOTH_PASSES {
360
+ var smooth = [Double](repeating: 0, count: result.count)
361
+ smooth[0] = result[0]
362
+ // Forward pass
363
+ for i in 1 ..< result.count {
364
+ smooth[i] = SMOOTH_FACTOR * smooth[i - 1] + (1 - SMOOTH_FACTOR) * result[i]
365
+ }
366
+ // Backward pass (zero-phase)
367
+ for i in stride(from: result.count - 2, through: 0, by: -1) {
368
+ smooth[i] = SMOOTH_FACTOR * smooth[i + 1] + (1 - SMOOTH_FACTOR) * smooth[i]
369
+ }
370
+ result = smooth
371
+ }
372
+ return result
373
+ }
374
+ }
@@ -0,0 +1,74 @@
1
+ import CoreLocation
2
+
3
+ final class HeadingManager: NSObject, CLLocationManagerDelegate {
4
+ static let shared = HeadingManager()
5
+
6
+ typealias Callback = (Float) -> Void
7
+
8
+ private var locationManager: CLLocationManager?
9
+ private var callback: Callback?
10
+ private var lastEmitted: Float = -1000
11
+ private var started = false
12
+
13
+ private static let minDelta: Float = 1.0
14
+
15
+ func start(callback: @escaping Callback) {
16
+ guard !started else { return }
17
+ started = true
18
+ self.callback = callback
19
+
20
+ // CLLocationManager must be created and used on the main thread (needs a run loop).
21
+ NSLog("[HeadingManager] start called — dispatching CLLocationManager init to main thread")
22
+ DispatchQueue.main.async { [weak self] in
23
+ guard let self, self.started else { return }
24
+ let lm = CLLocationManager()
25
+ lm.delegate = self
26
+ lm.headingFilter = Double(Self.minDelta)
27
+ self.locationManager = lm
28
+
29
+ let status = lm.authorizationStatus
30
+ NSLog("[HeadingManager] authorizationStatus=%d — %@", status.rawValue,
31
+ status == .notDetermined ? "requesting auth" : "starting heading updates")
32
+ if status == .notDetermined {
33
+ lm.requestWhenInUseAuthorization()
34
+ } else {
35
+ lm.startUpdatingHeading()
36
+ }
37
+ }
38
+ }
39
+
40
+ func stop() {
41
+ DispatchQueue.main.async { [weak self] in
42
+ self?.locationManager?.stopUpdatingHeading()
43
+ self?.locationManager = nil
44
+ }
45
+ callback = nil
46
+ lastEmitted = -1000
47
+ started = false
48
+ }
49
+
50
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
51
+ let status = manager.authorizationStatus
52
+ NSLog("[HeadingManager] authorizationChanged status=%d", status.rawValue)
53
+ if status == .authorizedWhenInUse || status == .authorizedAlways {
54
+ NSLog("[HeadingManager] authorized — starting heading updates")
55
+ manager.startUpdatingHeading()
56
+ }
57
+ }
58
+
59
+ func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
60
+ let raw = newHeading.magneticHeading
61
+ let degrees = Float(raw)
62
+ guard abs(angleDiff(degrees, lastEmitted)) >= Self.minDelta else { return }
63
+ lastEmitted = degrees
64
+ NSLog("[HeadingManager] heading=%.1f°", degrees)
65
+ callback?(degrees)
66
+ }
67
+
68
+ private func angleDiff(_ a: Float, _ b: Float) -> Float {
69
+ var d = (a - b).truncatingRemainder(dividingBy: 360)
70
+ if d > 180 { d -= 360 }
71
+ if d < -180 { d += 360 }
72
+ return d
73
+ }
74
+ }
@@ -0,0 +1,62 @@
1
+ import CoreLocation
2
+ import Foundation
3
+ import MapboxDirections
4
+
5
+ // Maps the Mapbox Directions maneuver (type + direction) to the string values
6
+ // the JS layer expects — the SAME vocabulary the Android Mapbox migration
7
+ // emits via NavigationManager.kt `mapManeuver()`, which is in turn the same
8
+ // vocabulary the old Google iOS path produced. The miniapp SDK's
9
+ // `ManeuverKind` is frozen, so these strings must not change.
10
+ //
11
+ // Mapbox Directions splits a maneuver into:
12
+ // • ManeuverType — depart / turn / fork / merge / arrive / continue / …
13
+ // • ManeuverDirection — left / right / slightLeft / slightRight / sharpLeft /
14
+ // sharpRight / straight / uTurn
15
+ // We combine them the way Android does (type drives the verb; direction
16
+ // drives left/right + slight/sharp variants).
17
+ //
18
+ // VERIFY-IN-XCODE: enum case spellings (`.turn`, `.slightLeft`, etc.) are from
19
+ // MapboxDirections v3. Confirm against the installed SDK if the compiler
20
+ // flags any case name.
21
+ func maneuverString(type: ManeuverType?, direction: ManeuverDirection?) -> String {
22
+ switch type {
23
+ case .some(.arrive):
24
+ return "ARRIVE"
25
+ case .some(.depart):
26
+ return "DEPART"
27
+ case .some(.reachFork), .some(.merge), .some(.takeOnRamp), .some(.takeOffRamp):
28
+ // Forks / merges / ramps map to the SLIGHT variant of their side.
29
+ switch direction {
30
+ case .some(.left), .some(.slightLeft), .some(.sharpLeft):
31
+ return "SLIGHT_LEFT"
32
+ case .some(.right), .some(.slightRight), .some(.sharpRight):
33
+ return "SLIGHT_RIGHT"
34
+ default:
35
+ return "STRAIGHT"
36
+ }
37
+ default:
38
+ // turn / continue / endOfRoad / roundabout / rotary / unknown — direction
39
+ // drives the left/right + slight/sharp/u-turn family.
40
+ return directionString(direction)
41
+ }
42
+ }
43
+
44
+ /// Map a ManeuverDirection to the left/right family used by the JS layer.
45
+ private func directionString(_ direction: ManeuverDirection?) -> String {
46
+ switch direction {
47
+ case .some(.left): return "TURN_LEFT"
48
+ case .some(.right): return "TURN_RIGHT"
49
+ case .some(.slightLeft): return "SLIGHT_LEFT"
50
+ case .some(.slightRight): return "SLIGHT_RIGHT"
51
+ case .some(.sharpLeft): return "SHARP_LEFT"
52
+ case .some(.sharpRight): return "SHARP_RIGHT"
53
+ case .some(.uTurn): return "U_TURN"
54
+ default: return "STRAIGHT"
55
+ }
56
+ }
57
+
58
+ /// Convert a list of route coordinates to the `[{lat,lng}]` array the JS bridge
59
+ /// expects (matching Android's RoutePoint shape).
60
+ func coordinatesToPoints(_ coords: [CLLocationCoordinate2D]) -> [[String: Double]] {
61
+ coords.map { ["lat": $0.latitude, "lng": $0.longitude] }
62
+ }