@glassly/crust 0.1.0-dev.1

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