@mentra/crust 0.1.0-beta.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 +41 -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 +1042 -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 +287 -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 +182 -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 +34 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +63 -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 +781 -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 +191 -0
  63. package/src/CrustModule.web.ts +66 -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,720 @@
1
+ import Combine
2
+ import CoreLocation
3
+ import Foundation
4
+ import MapboxDirections
5
+ import MapboxNavigationCore
6
+
7
+ /// NavigationManager (iOS)
8
+ ///
9
+ /// Singleton wrapper around the **Mapbox Navigation SDK v3 for iOS** (migrated
10
+ /// from the Google Navigation SDK). Mirrors the Android
11
+ /// `NavigationManager.kt`: owns the `MapboxNavigation` lifecycle, subscribes to
12
+ /// the Combine publishers (route progress, location, arrival, rerouting), and
13
+ /// fans out the SAME coarse callbacks the Google version exposed to
14
+ /// `CrustModule`.
15
+ ///
16
+ /// ## Contract (UNCHANGED from the Google implementation)
17
+ ///
18
+ /// The public surface — `start` / `stop` / `requestPermission` /
19
+ /// `simulateDeviation`, plus the `onEvent` / `onLocation` / `onRoute` payload
20
+ /// shapes — is identical to the Google version, so `CrustModule.swift` and
21
+ /// everything above it (the `@mentra/miniapp` SDK) does not change.
22
+ ///
23
+ /// ## iOS-vs-Android API differences (why this isn't a line-for-line port)
24
+ ///
25
+ /// - iOS v3 is **Combine publisher-based**, not observer-based. We hold
26
+ /// `AnyCancellable`s instead of registering observer objects.
27
+ /// - Routing is `async`: `routingProvider().calculateRoutes(options:)`.
28
+ /// - Simulation is a **CoreConfig location source** (`.simulation`), chosen at
29
+ /// provider-construction time — not a separate replay session object. Because
30
+ /// the source is fixed when the provider is built, switching sim on/off
31
+ /// rebuilds the provider (see `makeProvider`).
32
+ /// - Mapbox owns off-route detection + auto-reroute on iOS too, same as
33
+ /// Android — we observe, we don't re-derive.
34
+ ///
35
+ /// VERIFY-IN-XCODE markers flag the few exact field/case/method names that the
36
+ /// (auth-gated) v3 docs didn't let me confirm. Everything else is from the
37
+ /// official v3 examples.
38
+ // @MainActor: the Mapbox Navigation v3 API surface (MapboxNavigation,
39
+ // NavigationController, SessionController, the routeProgress/locationMatching
40
+ // publishers) is main-actor-isolated. All our Mapbox access already happens on
41
+ // the main thread (we dispatch to DispatchQueue.main in start/stop), so marking
42
+ // the whole manager @MainActor aligns the compiler with the actual runtime
43
+ // threading and removes the "main actor-isolated … from a nonisolated context"
44
+ // errors. CrustModule calls these from AsyncFunctions, which await across the
45
+ // boundary cleanly.
46
+ @MainActor
47
+ final class NavigationManager: NSObject {
48
+ static let shared = NavigationManager()
49
+
50
+ typealias EventCallback = ([String: Any]) -> Void
51
+ typealias LocationCallback = ([String: Any]) -> Void
52
+ typealias RouteCallback = ([String: Any]) -> Void
53
+ typealias StartCompletion = (Bool, String?) -> Void
54
+
55
+ // Callbacks wired up by CrustModule and cleared on stop().
56
+ private var onEvent: EventCallback?
57
+ private var onLocation: LocationCallback?
58
+ private var onRoute: RouteCallback?
59
+
60
+ // The Mapbox engine. Rebuilt per-trip because the location source
61
+ // (live vs simulation) is baked into CoreConfig at construction time.
62
+ private var provider: MapboxNavigationProvider?
63
+ private var mapboxNavigation: MapboxNavigation? { provider?.mapboxNavigation }
64
+
65
+ // Combine subscriptions — torn down on stop().
66
+ private var cancellables = Set<AnyCancellable>()
67
+
68
+ // First-fix gate. Mapbox has no synchronous "current location" getter; the
69
+ // device position arrives only via the locationMatching publisher AFTER the
70
+ // session starts. So we start the session, capture the first fix, THEN
71
+ // request the route from that real origin. Mirrors Android's
72
+ // `pendingRouteRequest`. nil once fired (one-shot).
73
+ private var pendingRouteRequest: ((_ originLat: Double, _ originLng: Double) -> Void)?
74
+
75
+ // Per-trip config captured from start().
76
+ private var tripStops: [(lat: Double, lng: Double)] = []
77
+ private var travelMode: String = "driving"
78
+ private var simulating: Bool = false
79
+ private var simulationSpeed: Double = 1.0
80
+
81
+ // Dedup the maneuver emission to ~1m granularity (matches Android's
82
+ // emitManeuverIfChanged so the "In X m" countdown updates ~1 Hz and stays in
83
+ // lockstep with the trip distance). Key = "type|distM|tripM".
84
+ private var lastEmittedManeuverKey: String?
85
+
86
+ // Arrival is reported once; the SDK keeps ticking COMPLETE afterwards.
87
+ private var arrivedHandled = false
88
+ // Off-route emitted once per episode (reset on reroute), same as Android.
89
+ private var offRouteEmitted = false
90
+ // The navigationRoutes publisher fires once with the INITIAL route (already
91
+ // emitted by requestAndStartRoute). We skip that first emission and treat
92
+ // every subsequent one as a reroute redraw. false until the first fires.
93
+ private var didEmitInitialRoute = false
94
+
95
+ // Last two forwarded coords — the Deviate dev walker derives its bearing
96
+ // from prev→last so the user keeps moving in *their* direction of travel.
97
+ private var lastReportedCoord: CLLocationCoordinate2D?
98
+ private var prevReportedCoord: CLLocationCoordinate2D?
99
+ private var deviateTimer: Timer?
100
+ private let DEVIATE_DURATION_S: Double = 10.0
101
+
102
+ // MARK: - Permissions
103
+
104
+ /// Mapbox needs no Terms & Conditions dialog (that was Google-specific).
105
+ /// Resolve immediately with `true` so the JS permission gate passes —
106
+ /// mirrors Android `ensureTermsAccepted`, which is a no-op on Mapbox.
107
+ /// (Location authorization itself is handled by the standard iOS prompts
108
+ /// driven from the NSLocation* Info.plist usage strings + CoreLocation.)
109
+ func requestPermission(completion: @escaping (Bool) -> Void) {
110
+ completion(true)
111
+ }
112
+
113
+ // MARK: - Start
114
+
115
+ func start(
116
+ stops: [(lat: Double, lng: Double)],
117
+ mode: String,
118
+ simulate: Bool,
119
+ speedMultiplier: Double,
120
+ missedTurnRerouteMeters: Double? = nil,
121
+ onEvent: @escaping EventCallback,
122
+ onLocation: @escaping LocationCallback,
123
+ onRoute: @escaping RouteCallback,
124
+ completion: @escaping StartCompletion
125
+ ) {
126
+ // Clean baseline — like Android's start() calling stop() first.
127
+ stopInternal()
128
+
129
+ guard !stops.isEmpty else {
130
+ completion(false, "at least one stop is required")
131
+ return
132
+ }
133
+
134
+ self.onEvent = onEvent
135
+ self.onLocation = onLocation
136
+ self.onRoute = onRoute
137
+ self.tripStops = stops
138
+ self.travelMode = mode
139
+ self.simulating = simulate
140
+ self.simulationSpeed = max(0.5, min(speedMultiplier, 50))
141
+ self.arrivedHandled = false
142
+ self.offRouteEmitted = false
143
+ self.didEmitInitialRoute = false
144
+ self.lastEmittedManeuverKey = nil
145
+ // (missedTurnRerouteMeters is intentionally unused on the Mapbox path —
146
+ // Mapbox owns reroute natively, same decision as Android. Kept in the
147
+ // signature for contract parity with CrustModule.)
148
+ _ = missedTurnRerouteMeters
149
+
150
+ DispatchQueue.main.async { [weak self] in
151
+ guard let self else { return }
152
+
153
+ // Build the provider with the right location source. Simulation is a
154
+ // CoreConfig choice; `.simulation` drives a synthetic puck along the
155
+ // active route once one is set.
156
+ let provider = self.makeProvider(simulate: simulate)
157
+ self.provider = provider
158
+
159
+ // `mapboxNavigation` is non-optional on the provider.
160
+ let nav = provider.mapboxNavigation
161
+
162
+ // Subscribe to the publishers BEFORE starting the session so we don't
163
+ // miss the first ticks.
164
+ self.subscribe(nav)
165
+
166
+ // Start a free-drive (passive) session so location starts flowing; the
167
+ // first fix satisfies the gate below, then we request the route and
168
+ // switch to active guidance.
169
+ // VERIFY-IN-XCODE: free-drive start method name. v3 examples show
170
+ // `nav.tripSession().startFreeDrive()`.
171
+ nav.tripSession().startFreeDrive()
172
+
173
+ // First-fix gate: request the route from the device's real origin.
174
+ self.pendingRouteRequest = { [weak self] originLat, originLng in
175
+ self?.requestAndStartRoute(
176
+ nav: nav,
177
+ originLat: originLat,
178
+ originLng: originLng,
179
+ completion: completion
180
+ )
181
+ }
182
+
183
+ // First-fix TIMEOUT (live AND sim). The gate above waits for Mapbox's
184
+ // locationMatching publisher to emit the first fix before requesting the
185
+ // route. On a COLD APP LAUNCH that first fix can be slow or never arrive
186
+ // until the CLLocationManager fully spins up — which is exactly the
187
+ // "first nav after launch hangs at Starting…, works on the 2nd try" bug
188
+ // (the 2nd try has a warm location manager). So we ALWAYS arm a timeout:
189
+ // if no fix satisfies the gate in time, request the route from a one-shot
190
+ // CoreLocation fix / last known location so we never hang.
191
+ self.armFirstFixTimeout(nav: nav, completion: completion)
192
+ }
193
+ }
194
+
195
+ /// Build a fresh MapboxNavigationProvider. `.simulation` vs `.live` is fixed
196
+ /// at construction, which is why a sim toggle rebuilds the provider.
197
+ private func makeProvider(simulate: Bool) -> MapboxNavigationProvider {
198
+ // Explicit reroute config: native off-route detection + auto-reroute ON
199
+ // (this is the SDK default — detectsReroute defaults to true — but we set it
200
+ // explicitly so the intent is visible and can't silently regress). The
201
+ // navigator runs this off-route check continuously while in active guidance.
202
+ let routingConfig = RoutingConfig(
203
+ rerouteConfig: RerouteConfig(detectsReroute: true)
204
+ )
205
+ let coreConfig = CoreConfig(
206
+ routingConfig: routingConfig,
207
+ locationSource: simulate ? .simulation(initialLocation: nil) : .live
208
+ )
209
+ return MapboxNavigationProvider(coreConfig: coreConfig)
210
+ }
211
+
212
+ /// Subscribe to route progress, location, arrival, and reroute publishers.
213
+ private func subscribe(_ nav: MapboxNavigation) {
214
+ let navigation = nav.navigation()
215
+
216
+ // Location stream. `MapMatchingState` carries BOTH the raw GPS fix
217
+ // (`.location`) and the snapped/map-matched fix (`.enhancedLocation`). We
218
+ // forward the RAW location for the phone puck so that when the user
219
+ // physically diverges from the route, the puck visibly leaves the line
220
+ // (matching Android, which reports raw GPS). Using `.enhancedLocation` here
221
+ // was the bug: it snaps the puck onto the route, hiding all divergence.
222
+ // This is also our first-fix gate trigger.
223
+ navigation.locationMatching
224
+ .sink { [weak self] matched in
225
+ let r = matched.mapMatchingResult
226
+ print(String(format: "[NavMgr] loc raw=(%.6f,%.6f) offRoad=%@ offRoadProb=%.2f",
227
+ matched.location.coordinate.latitude,
228
+ matched.location.coordinate.longitude,
229
+ r.isOffRoad ? "YES" : "no",
230
+ r.offRoadProbability))
231
+ self?.handleLocation(matched.location, isOffRoad: r.isOffRoad)
232
+ }
233
+ .store(in: &cancellables)
234
+
235
+ // Route progress — drives the maneuver card + arrival.
236
+ // VERIFY-IN-XCODE: `.routeProgress` publisher emits `RouteProgress?`
237
+ // (v3 examples map `\.?.routeProgress`). Unwrap before use.
238
+ navigation.routeProgress
239
+ .sink { [weak self] progressState in
240
+ guard let progress = progressState?.routeProgress else { return }
241
+ self?.handleRouteProgress(progress)
242
+ }
243
+ .store(in: &cancellables)
244
+
245
+ // Rerouting — Mapbox detects off-route and fetches a new route NATIVELY
246
+ // (same as Android; we observe, we don't re-derive). `rerouting` is an
247
+ // event publisher of `ReroutingStatus`, whose `.event` is one of
248
+ // `.FetchingRoute` / `.Fetched` / `.Failed` / `.Interrupted`. We emit the
249
+ // "rerouting" event when the fetch begins so the glasses show the
250
+ // "Rerouting…" HUD + the phone shows the toast.
251
+ navigation.rerouting
252
+ .sink { [weak self] status in
253
+ guard let self else { return }
254
+ print("[NavMgr] rerouting event: \(type(of: status.event))")
255
+ switch status.event {
256
+ case is ReroutingStatus.Events.FetchingRoute:
257
+ // A new route fetch has started — tell JS we're rerouting. The actual
258
+ // new polyline arrives via the navigationRoutes publisher below.
259
+ self.lastEmittedManeuverKey = nil
260
+ self.onEvent?(["kind": "rerouting"])
261
+ case is ReroutingStatus.Events.Failed:
262
+ self.onEvent?(["kind": "error", "message": "reroute failed"])
263
+ default:
264
+ // .Fetched / .Interrupted — the route update is delivered separately.
265
+ break
266
+ }
267
+ }
268
+ .store(in: &cancellables)
269
+
270
+ // Arrival — the authoritative signal is the waypoints-arrival publisher
271
+ // (final destination), not a distance threshold. Emit `arrived` once.
272
+ navigation.waypointsArrival
273
+ .sink { [weak self] status in
274
+ guard let self else { return }
275
+ if status.event is WaypointArrivalStatus.Events.ToFinalDestination {
276
+ if !self.arrivedHandled {
277
+ self.arrivedHandled = true
278
+ self.onEvent?(["kind": "arrived"])
279
+ }
280
+ }
281
+ }
282
+ .store(in: &cancellables)
283
+
284
+ // Route updates — `navigationRoutes` emits the active NavigationRoutes
285
+ // whenever they change: the initial route, AND every reroute. We skip the
286
+ // very first emission (that's the initial route, already emitted by
287
+ // requestAndStartRoute) and treat every subsequent non-nil emission as a
288
+ // reroute: reset the maneuver dedup and re-emit the new polyline so the
289
+ // phone map + glasses redraw it. This is the RoutesObserver-equivalent and
290
+ // is the reliable redraw signal.
291
+ nav.tripSession().navigationRoutes
292
+ .sink { [weak self] routes in
293
+ guard let self, let routes else { return }
294
+ if !self.didEmitInitialRoute {
295
+ // First emission is the initial route — requestAndStartRoute already
296
+ // emitted it; just record that we've seen it.
297
+ self.didEmitInitialRoute = true
298
+ return
299
+ }
300
+ // Subsequent emission = reroute. Redraw.
301
+ self.lastEmittedManeuverKey = nil
302
+ self.emitRoute(routes)
303
+ }
304
+ .store(in: &cancellables)
305
+ }
306
+
307
+ /// Safety net for the first-fix gate. The gate fires when Mapbox's
308
+ /// locationMatching emits the first fix; this fallback fires if that's slow
309
+ /// (cold-launch) or never comes. It retries a few times — on a cold start the
310
+ /// CLLocationManager populates `.location` within a second or two of starting
311
+ /// updates — and once it has any origin (Mapbox fix, our keep-alive manager,
312
+ /// or a fresh CLLocationManager) it requests the route from it. Only bails
313
+ /// with an error after exhausting all retries with no location at all.
314
+ private func armFirstFixTimeout(
315
+ nav: MapboxNavigation,
316
+ completion: @escaping StartCompletion,
317
+ attempt: Int = 0
318
+ ) {
319
+ let maxAttempts = 8 // ~8 × 0.75s ≈ 6s total before giving up
320
+ let interval: TimeInterval = 0.75
321
+ DispatchQueue.main.asyncAfter(deadline: .now() + interval) { [weak self] in
322
+ guard let self else { return }
323
+ // Gate already fired (a real fix arrived) — nothing to do.
324
+ guard let pending = self.pendingRouteRequest else { return }
325
+
326
+ // Try every origin source we have, freshest first.
327
+ let origin = self.lastReportedCoord
328
+ ?? CLLocationManager().location?.coordinate
329
+
330
+ if let origin {
331
+ print("[NavMgr] first-fix timeout fallback fired (attempt \(attempt)) — requesting route from last-known origin")
332
+ self.pendingRouteRequest = nil
333
+ pending(origin.latitude, origin.longitude)
334
+ return
335
+ }
336
+
337
+ // No location yet — keep retrying until we run out of attempts.
338
+ if attempt + 1 < maxAttempts {
339
+ self.armFirstFixTimeout(nav: nav, completion: completion, attempt: attempt + 1)
340
+ } else {
341
+ print("[NavMgr] first-fix timeout — no location after \(maxAttempts) attempts, giving up")
342
+ self.pendingRouteRequest = nil
343
+ completion(false, "no location fix to start navigation")
344
+ }
345
+ }
346
+ }
347
+
348
+ /// Build NavigationRouteOptions from origin + stops, request the route,
349
+ /// set it, switch to active guidance, and emit it. Mirrors Android's
350
+ /// `requestAndStartRoute`.
351
+ private func requestAndStartRoute(
352
+ nav: MapboxNavigation,
353
+ originLat: Double,
354
+ originLng: Double,
355
+ completion: @escaping StartCompletion
356
+ ) {
357
+ var waypoints: [Waypoint] = []
358
+ waypoints.append(Waypoint(coordinate: CLLocationCoordinate2D(latitude: originLat, longitude: originLng)))
359
+ for stop in tripStops {
360
+ waypoints.append(Waypoint(coordinate: CLLocationCoordinate2D(latitude: stop.lat, longitude: stop.lng)))
361
+ }
362
+
363
+ // VERIFY-IN-XCODE: NavigationRouteOptions init + profileIdentifier param.
364
+ let options = NavigationRouteOptions(
365
+ waypoints: waypoints,
366
+ profileIdentifier: profileFor(travelMode)
367
+ )
368
+
369
+ // VERIFY-IN-XCODE: `routingProvider().calculateRoutes(options:)` returns a
370
+ // Task whose `.result` is `Result<NavigationRoutes, Error>` (v3 example).
371
+ let task = nav.routingProvider().calculateRoutes(options: options)
372
+ Task { [weak self] in
373
+ guard let self else { return }
374
+ switch await task.result {
375
+ case .success(let navigationRoutes):
376
+ await MainActor.run {
377
+ // v3 SessionController: startActiveGuidance(with:startLegIndex:).
378
+ // Off-route detection + auto-reroute only run in ACTIVE GUIDANCE — so
379
+ // this call (not the earlier free-drive) is what arms rerouting.
380
+ print("[NavMgr] startActiveGuidance — \(navigationRoutes.mainRoute.route.legs.count) legs, profile=\(self.travelMode)")
381
+ nav.tripSession().startActiveGuidance(with: navigationRoutes, startLegIndex: 0)
382
+ self.emitRoute(navigationRoutes)
383
+ completion(true, nil)
384
+ }
385
+ case .failure(let error):
386
+ await MainActor.run {
387
+ completion(false, "route request failed: \(error.localizedDescription)")
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ /// SDK-agnostic mode string → Mapbox Directions profile. Google's
394
+ /// `two_wheeler` has no Mapbox equivalent → driving (migration doc #3).
395
+ private func profileFor(_ mode: String) -> ProfileIdentifier {
396
+ switch mode.lowercased() {
397
+ case "walking": return .walking
398
+ case "cycling": return .cycling
399
+ case "two_wheeler": return .automobile
400
+ case "driving": return .automobileAvoidingTraffic
401
+ default: return .automobileAvoidingTraffic
402
+ }
403
+ }
404
+
405
+ // MARK: - Stop
406
+
407
+ func stop() {
408
+ DispatchQueue.main.async { [weak self] in
409
+ self?.stopInternal()
410
+ }
411
+ }
412
+
413
+ private func stopInternal() {
414
+ deviateTimer?.invalidate()
415
+ deviateTimer = nil
416
+ cancellables.forEach { $0.cancel() }
417
+ cancellables.removeAll()
418
+ // Trip-session stop: SessionController.setToIdle(). `mapboxNavigation` is
419
+ // non-optional, so only `provider?` carries the optional chain.
420
+ provider?.mapboxNavigation.tripSession().setToIdle()
421
+ provider = nil
422
+ onEvent = nil
423
+ onLocation = nil
424
+ onRoute = nil
425
+ pendingRouteRequest = nil
426
+ tripStops = []
427
+ arrivedHandled = false
428
+ offRouteEmitted = false
429
+ didEmitInitialRoute = false
430
+ lastEmittedManeuverKey = nil
431
+ lastReportedCoord = nil
432
+ prevReportedCoord = nil
433
+ }
434
+
435
+ // MARK: - Location handling
436
+
437
+ private func handleLocation(_ location: CLLocation, isOffRoad: Bool = false) {
438
+ let coord = location.coordinate
439
+
440
+ // First-fix gate: fire the deferred route request once.
441
+ if let pending = pendingRouteRequest {
442
+ pendingRouteRequest = nil
443
+ pending(coord.latitude, coord.longitude)
444
+ }
445
+
446
+ prevReportedCoord = lastReportedCoord
447
+ lastReportedCoord = coord
448
+
449
+ onLocation?([
450
+ "lat": coord.latitude,
451
+ "lng": coord.longitude,
452
+ "accuracy": location.horizontalAccuracy,
453
+ "timestamp": location.timestamp.timeIntervalSince1970 * 1000,
454
+ ])
455
+ // Off-route DETECTION + auto-reroute is fully owned by Mapbox (see the
456
+ // `rerouting` publisher in subscribe()) — no hand-rolled perpendicular
457
+ // distance check here, matching Android. `isOffRoad` is the map-matcher's
458
+ // own opinion; currently informational (the reroute publisher drives the
459
+ // HUD), kept available for future advisory use.
460
+ _ = isOffRoad
461
+ }
462
+
463
+ // MARK: - Route progress → maneuver
464
+
465
+ private func handleRouteProgress(_ progress: RouteProgress) {
466
+ // Arrival. VERIFY-IN-XCODE: completion check. v3 RouteProgress exposes a
467
+ // session/route state; candidates: `progress.currentState == .complete`
468
+ // or `progress.fractionTraveled >= 1`. Android uses
469
+ // `RouteProgressState.COMPLETE`.
470
+ if isArrived(progress) {
471
+ if !arrivedHandled {
472
+ arrivedHandled = true
473
+ onEvent?(["kind": "arrived"])
474
+ if simulating {
475
+ // In sim the puck is parked at the destination; nothing more to do
476
+ // on iOS (no replay session to tear back to live). The next start()
477
+ // rebuilds the provider cleanly.
478
+ }
479
+ }
480
+ return
481
+ }
482
+
483
+ // Distance to final destination + ETA.
484
+ // VERIFY-IN-XCODE: `progress.distanceRemaining` (whole route, meters) and
485
+ // `progress.durationRemaining` (seconds).
486
+ let distToDest = Int(progress.distanceRemaining.rounded())
487
+ let timeToDest = Int(progress.durationRemaining.rounded())
488
+
489
+ // The UPCOMING step is what the user is walking toward (matches Android:
490
+ // upcomingStep.maneuver + currentStepProgress.distanceRemaining).
491
+ // VERIFY-IN-XCODE: leg/step accessors:
492
+ // progress.currentLegProgress.currentStepProgress.distanceRemaining
493
+ // progress.currentLegProgress.upcomingStep (RouteStep?)
494
+ // progress.currentLegProgress.currentStep (RouteStep)
495
+ let legProgress = progress.currentLegProgress
496
+ let stepProgress = legProgress.currentStepProgress
497
+ let upcomingStep = legProgress.upcomingStep
498
+ let currentStep = stepProgress.step
499
+
500
+ // turnStep = upcoming normally; current only on the final/arrival leg.
501
+ let turnStep = upcomingStep ?? currentStep
502
+ let distToManeuver = Int(stepProgress.distanceRemaining.rounded())
503
+
504
+ // Maneuver kind from the turn step.
505
+ // VERIFY-IN-XCODE: `turnStep.maneuverType` / `.maneuverDirection`.
506
+ let maneuver = maneuverString(type: turnStep.maneuverType, direction: turnStep.maneuverDirection)
507
+
508
+ // Roads: current step's name = road we're on; upcoming step's name = the
509
+ // road being entered at the turn.
510
+ // VERIFY-IN-XCODE: `RouteStep.names?.first` / `.instructionsDisplayedAlongStep`
511
+ // / `.name`. MapboxDirections exposes `names: [String]?`.
512
+ let currentRoad = currentStep.names?.first?.nonBlank
513
+ let nextStepRoad = upcomingStep?.names?.first?.nonBlank
514
+
515
+ // Verbatim instruction for the turn we're showing (the upcoming turn).
516
+ // VERIFY-IN-XCODE: the instruction text. Candidates:
517
+ // turnStep.instructions (String)
518
+ // turnStep.instructionsDisplayedAlongStep?.first?.primaryInstruction.text
519
+ let instruction = turnStep.instructions.nonBlank
520
+
521
+ emitManeuverIfChanged(
522
+ maneuverType: maneuver,
523
+ distanceMeters: distToManeuver,
524
+ fromRoad: currentRoad,
525
+ nextStepRoad: nextStepRoad,
526
+ distanceToDestinationMeters: distToDest,
527
+ timeToDestinationSeconds: timeToDest,
528
+ instruction: instruction
529
+ )
530
+ }
531
+
532
+ /// Per-metre dedup, identical policy to Android's emitManeuverIfChanged, so
533
+ /// the "In X m" countdown updates ~1 Hz and stays in lockstep with the trip
534
+ /// distance shown on the HUD.
535
+ private func emitManeuverIfChanged(
536
+ maneuverType: String,
537
+ distanceMeters: Int,
538
+ fromRoad: String?,
539
+ nextStepRoad: String?,
540
+ distanceToDestinationMeters: Int,
541
+ timeToDestinationSeconds: Int,
542
+ instruction: String?
543
+ ) {
544
+ let distBucket = distanceMeters >= 0 ? distanceMeters : -1
545
+ let tripBucket = distanceToDestinationMeters >= 0 ? distanceToDestinationMeters : -1
546
+ let key = "\(maneuverType)|\(distBucket)|\(tripBucket)"
547
+ if key == lastEmittedManeuverKey { return }
548
+ lastEmittedManeuverKey = key
549
+
550
+ var payload: [String: Any] = [
551
+ "kind": "maneuver",
552
+ "maneuverType": maneuverType,
553
+ "distanceMeters": distanceMeters,
554
+ "distanceToDestinationMeters": distanceToDestinationMeters,
555
+ "timeToDestinationSeconds": timeToDestinationSeconds,
556
+ ]
557
+ if let fromRoad { payload["fromRoad"] = fromRoad; payload["toRoad"] = fromRoad }
558
+ if let nextStepRoad { payload["nextStepRoad"] = nextStepRoad }
559
+ if let instruction { payload["instruction"] = instruction }
560
+ onEvent?(payload)
561
+ }
562
+
563
+ /// VERIFY-IN-XCODE: arrival predicate for v3 RouteProgress.
564
+ private func isArrived(_ progress: RouteProgress) -> Bool {
565
+ // Prefer an explicit completion state if the SDK exposes one; else fall
566
+ // back to "essentially no distance left".
567
+ // return progress.currentState == .complete
568
+ return progress.distanceRemaining <= 1.0
569
+ }
570
+
571
+ // MARK: - Route emission
572
+
573
+ private func emitRoute(_ navigationRoutes: NavigationRoutes) {
574
+ // VERIFY-IN-XCODE: how to read the chosen route's geometry + steps.
575
+ // navigationRoutes.mainRoute.route (Route)
576
+ // route.shape?.coordinates ([CLLocationCoordinate2D])
577
+ // route.legs.flatMap(\.steps) ([RouteStep])
578
+ // mainRoute is non-optional; `.route` is the underlying Route.
579
+ let route = navigationRoutes.mainRoute.route
580
+ let coords = route.shape?.coordinates ?? []
581
+ let points = coordinatesToPoints(coords)
582
+
583
+ var payload: [String: Any] = ["points": points]
584
+
585
+ // Build steps with polyline index + road + maneuver + distance, matching
586
+ // Android's RouteStep shape (lat, lng, routeIndex, road, maneuver,
587
+ // distanceMeters). Each step is anchored to its maneuver location.
588
+ var steps: [[String: Any]] = []
589
+ for leg in route.legs {
590
+ for step in leg.steps {
591
+ // VERIFY-IN-XCODE: `step.maneuverLocation` (CLLocationCoordinate2D),
592
+ // `step.distance` (meters), `step.names`, maneuver type/direction.
593
+ let loc = step.maneuverLocation
594
+ let idx = closestPolylineIndex(in: points, lat: loc.latitude, lng: loc.longitude)
595
+ var entry: [String: Any] = [
596
+ "lat": loc.latitude,
597
+ "lng": loc.longitude,
598
+ "routeIndex": idx,
599
+ "maneuver": maneuverString(type: step.maneuverType, direction: step.maneuverDirection),
600
+ "distanceMeters": Int(step.distance.rounded()),
601
+ ]
602
+ if let road = step.names?.first?.nonBlank { entry["road"] = road }
603
+ steps.append(entry)
604
+ }
605
+ }
606
+ if !steps.isEmpty { payload["steps"] = steps }
607
+
608
+ onRoute?(payload)
609
+ }
610
+
611
+ private func closestPolylineIndex(in points: [[String: Double]], lat: Double, lng: Double) -> Int {
612
+ var best = 0
613
+ var bestD = Double.greatestFiniteMagnitude
614
+ for (i, p) in points.enumerated() {
615
+ let dx = (p["lat"] ?? 0) - lat
616
+ let dy = (p["lng"] ?? 0) - lng
617
+ let d = dx * dx + dy * dy
618
+ if d < bestD { bestD = d; best = i }
619
+ }
620
+ return best
621
+ }
622
+
623
+ // MARK: - Simulate deviation (dev only)
624
+
625
+ /// Walk the user STRAIGHT FORWARD in their current direction of travel for
626
+ /// DEVIATE_DURATION_S, pushing synthesized coords through `onLocation` so
627
+ /// they go off-route and Mapbox's native reroute kicks in. Bearing comes
628
+ /// from prev→last forwarded coords (their real direction of travel).
629
+ /// `offsetMeters` is legacy/ignored. Mirrors Android `simulateDeviation`.
630
+ func simulateDeviation(offsetMeters: Double) {
631
+ DispatchQueue.main.async { [weak self] in
632
+ guard let self else { return }
633
+ let origin = self.lastReportedCoord
634
+ ?? CLLocationManager().location?.coordinate
635
+ ?? CLLocationCoordinate2D(latitude: 0, longitude: 0)
636
+
637
+ let bearing: Double = {
638
+ if let prev = self.prevReportedCoord, let last = self.lastReportedCoord {
639
+ let d = self.haversineMeters(prev, last)
640
+ if d > 0.5 { return self.bearingDegrees(from: prev, to: last) }
641
+ }
642
+ return .nan
643
+ }()
644
+ guard !bearing.isNaN else {
645
+ print("[NavigationManager] simulateDeviation: no recent movement to infer bearing")
646
+ return
647
+ }
648
+ _ = offsetMeters
649
+
650
+ self.deviateTimer?.invalidate()
651
+ let baselineMps = 1.4
652
+ let stepMeters = max(0.1, baselineMps * self.simulationSpeed * 0.4)
653
+ let interval: TimeInterval = 0.4
654
+ let totalTicks = Int((self.DEVIATE_DURATION_S / interval).rounded())
655
+ var cursor = origin
656
+ var ticksRemaining = totalTicks
657
+ // The Timer fires on a @Sendable closure, but every property it touches is
658
+ // @MainActor-isolated. Hop onto the main actor inside the tick so the
659
+ // mutation is actor-safe (clears the Swift-6 concurrency warnings and
660
+ // matches the rest of this class's main-actor model).
661
+ self.deviateTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { t in
662
+ Task { @MainActor [weak self] in
663
+ guard let self else { t.invalidate(); return }
664
+ if ticksRemaining <= 0 {
665
+ t.invalidate(); self.deviateTimer = nil
666
+ return
667
+ }
668
+ cursor = self.projectCoordinate(from: cursor, distanceMeters: stepMeters, bearingDegrees: bearing)
669
+ self.prevReportedCoord = self.lastReportedCoord
670
+ self.lastReportedCoord = cursor
671
+ self.onLocation?([
672
+ "lat": cursor.latitude,
673
+ "lng": cursor.longitude,
674
+ "accuracy": 5.0,
675
+ "timestamp": Date().timeIntervalSince1970 * 1000,
676
+ ])
677
+ ticksRemaining -= 1
678
+ }
679
+ }
680
+ }
681
+ }
682
+
683
+ // MARK: - Geo helpers
684
+
685
+ private func bearingDegrees(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
686
+ let toRad = Double.pi / 180, toDeg = 180 / Double.pi
687
+ let lat1 = a.latitude * toRad, lat2 = b.latitude * toRad
688
+ let dLng = (b.longitude - a.longitude) * toRad
689
+ let y = sin(dLng) * cos(lat2)
690
+ let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLng)
691
+ return (atan2(y, x) * toDeg + 360).truncatingRemainder(dividingBy: 360)
692
+ }
693
+
694
+ private func projectCoordinate(from origin: CLLocationCoordinate2D, distanceMeters: Double, bearingDegrees: Double) -> CLLocationCoordinate2D {
695
+ let earthRadius = 6_371_000.0
696
+ let angular = distanceMeters / earthRadius
697
+ let bearing = bearingDegrees * .pi / 180
698
+ let lat1 = origin.latitude * .pi / 180, lng1 = origin.longitude * .pi / 180
699
+ let lat2 = asin(sin(lat1) * cos(angular) + cos(lat1) * sin(angular) * cos(bearing))
700
+ let lng2 = lng1 + atan2(sin(bearing) * sin(angular) * cos(lat1), cos(angular) - sin(lat1) * sin(lat2))
701
+ return CLLocationCoordinate2D(latitude: lat2 * 180 / .pi, longitude: lng2 * 180 / .pi)
702
+ }
703
+
704
+ private func haversineMeters(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> Double {
705
+ let R = 6_371_000.0, toRad = Double.pi / 180
706
+ let dLat = (b.latitude - a.latitude) * toRad
707
+ let dLng = (b.longitude - a.longitude) * toRad
708
+ let lat1 = a.latitude * toRad, lat2 = b.latitude * toRad
709
+ let s1 = sin(dLat / 2), s2 = sin(dLng / 2)
710
+ let x = s1 * s1 + s2 * s2 * cos(lat1) * cos(lat2)
711
+ return 2 * R * asin(min(1.0, sqrt(x)))
712
+ }
713
+ }
714
+
715
+ private extension String {
716
+ var nonBlank: String? {
717
+ let t = trimmingCharacters(in: .whitespacesAndNewlines)
718
+ return t.isEmpty ? nil : t
719
+ }
720
+ }