@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,1445 @@
1
+ package com.mentra.crust.navigation
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.util.Log
6
+ import com.mapbox.api.directions.v5.DirectionsCriteria
7
+ import com.mapbox.api.directions.v5.models.DirectionsRoute
8
+ import com.mapbox.api.directions.v5.models.ManeuverModifier
9
+ import com.mapbox.api.directions.v5.models.RouteOptions
10
+ import com.mapbox.api.directions.v5.models.StepManeuver
11
+ import com.mapbox.common.MapboxOptions
12
+ import com.mapbox.common.location.Location
13
+ import com.mapbox.geojson.Point
14
+ import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
15
+ import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
16
+ import com.mapbox.navigation.base.extensions.applyLanguageAndVoiceUnitOptions
17
+ import com.mapbox.navigation.base.options.NavigationOptions
18
+ import com.mapbox.navigation.base.route.NavigationRoute
19
+ import com.mapbox.navigation.base.route.NavigationRouterCallback
20
+ import com.mapbox.navigation.base.route.RouterFailure
21
+ import com.mapbox.navigation.base.trip.model.RouteProgress
22
+ import com.mapbox.navigation.base.trip.model.RouteProgressState
23
+ import com.mapbox.navigation.core.MapboxNavigation
24
+ import com.mapbox.navigation.core.MapboxNavigationProvider
25
+ import com.mapbox.navigation.core.directions.session.RoutesExtra
26
+ import com.mapbox.navigation.core.directions.session.RoutesObserver
27
+ import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult
28
+ import com.mapbox.navigation.core.reroute.RerouteController
29
+ import com.mapbox.navigation.core.reroute.RerouteState
30
+ import com.mapbox.navigation.core.replay.MapboxReplayer
31
+ import com.mapbox.navigation.core.replay.history.ReplayEventBase
32
+ import com.mapbox.navigation.core.replay.history.ReplayEventLocation
33
+ import com.mapbox.navigation.core.replay.history.ReplayEventUpdateLocation
34
+ import com.mapbox.navigation.core.replay.route.ReplayRouteMapper
35
+ import com.mapbox.navigation.core.replay.route.ReplayRouteOptions
36
+ import com.mapbox.navigation.core.replay.route.ReplayRouteSession
37
+ import com.mapbox.navigation.core.replay.route.ReplayRouteSessionOptions
38
+ import com.mapbox.navigation.core.trip.session.LocationMatcherResult
39
+ import com.mapbox.navigation.core.trip.session.LocationObserver
40
+ import com.mapbox.navigation.core.trip.session.OffRouteObserver
41
+ import com.mapbox.navigation.core.trip.session.RouteProgressObserver
42
+
43
+ /**
44
+ * NavigationManager
45
+ *
46
+ * Singleton wrapper around the **Mapbox Navigation SDK v3** for Android
47
+ * (migrated from the Google Navigation SDK). Owns the MapboxNavigation
48
+ * lifecycle, attaches observers, and exposes coarse callbacks consumed by
49
+ * CrustModule (which forwards them as events to JS).
50
+ *
51
+ * Headless: never mounts a MapView. The mobile app does not render a
52
+ * map natively (the miniapp WebView owns the visible map).
53
+ *
54
+ * ## Contract (UNCHANGED from the Google implementation)
55
+ *
56
+ * The public surface — `start` / `stop` / `ensureTermsAccepted` /
57
+ * `resetTermsAccepted` / `simulateDeviation` / `setWrongSidewalkOffset` /
58
+ * `setSkipCrossings`, plus the `StartOptions` / `Callbacks` /
59
+ * `ManeuverPayload` / `LocationPayload` / `RoutePoint` / `RouteStep` types —
60
+ * is byte-identical to the Google version. CrustModule and everything above
61
+ * the bridge (the `@mentra/miniapp` SDK, the PivotEngine) are unaffected by
62
+ * this migration. See issues/mapbox-navigation-migration.md.
63
+ *
64
+ * ## How Mapbox differs from Google (mapped onto the same contract)
65
+ *
66
+ * - **Off-route + reroute is automatic.** Mapbox detects off-route and
67
+ * re-requests a route by default. We surface `OffRouteObserver` →
68
+ * `onOffRoute`, `RerouteState` transitions → `onRerouting`, and
69
+ * `RoutesObserver` (new route) → `onRoute`. No hand-rolled 30m
70
+ * perpendicular detector for the live trip.
71
+ * - **Steps arrive inline** on each `RouteProgress` / off the
72
+ * `NavigationRoute` directlegs — no Messenger-IPC NavInfo service
73
+ * (NavInfoReceiverService is deleted). Road names + maneuver come from
74
+ * the Directions `LegStep` / `BannerInstructions`.
75
+ * - **No T&C dialog.** Mapbox has no runtime terms screen, so
76
+ * `ensureTermsAccepted` resolves immediately `true` and
77
+ * `resetTermsAccepted` is a no-op — keeping the callback shape the
78
+ * miniapp's `requestPermission()` expects.
79
+ * - **Enhanced (map-matched) location** replaces RoadSnappedLocationProvider
80
+ * via `LocationObserver.onNewLocationMatcherResult`.
81
+ * - **Simulation** uses `MapboxReplayer` + `ReplayRouteMapper`; the three
82
+ * dev walkers (deviate / wrong-sidewalk / skip-crossings) are
83
+ * re-implemented by pushing synthetic `ReplayEventUpdateLocation`s.
84
+ */
85
+ @OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
86
+ object NavigationManager {
87
+ private const val TAG = "NavigationManager"
88
+
89
+ private var mapboxNavigation: MapboxNavigation? = null
90
+ private var appContext: Context? = null
91
+
92
+ private var activeCallbacks: Callbacks? = null
93
+ private var lastEmittedKey: String? = null
94
+
95
+ /** True iff the active trip is using simulated locations. Drives reroute-restart logic. */
96
+ private var simulating: Boolean = false
97
+ /** Saved speed multiplier so deviation/reroute can resume at the same pace. */
98
+ private var simulationSpeed: Float = 1f
99
+ /**
100
+ * Set once when an arrival is handled so the repeated COMPLETE
101
+ * RouteProgress ticks the SDK keeps emitting after arrival don't
102
+ * re-trigger the sim→real-GPS handoff. Cleared on start()/stop().
103
+ */
104
+ private var arrivedHandled: Boolean = false
105
+
106
+ /** Most recent (map-matched) fix. Used to compute distance-to-maneuver and dev walkers. */
107
+ private var lastFixLat: Double = Double.NaN
108
+ private var lastFixLng: Double = Double.NaN
109
+ /**
110
+ * The sample BEFORE lastFix. Used by the Deviate dev button to derive
111
+ * the user's actual direction of travel (prev → last) so the
112
+ * straight-walk uses *their* bearing, not the route's local bearing.
113
+ */
114
+ private var prevFixLat: Double = Double.NaN
115
+ private var prevFixLng: Double = Double.NaN
116
+ /** Most recent speed in m/s, off the enhanced location. Null until first sample. */
117
+ private var lastSpeedMps: Float? = null
118
+
119
+ /** Edge-debounce for Mapbox's OffRouteObserver: true between an off-route
120
+ * signal and the next fresh route, so we emit onOffRoute once per
121
+ * divergence. Cleared when a (rerouted) route lands. */
122
+ private var offRouteFired: Boolean = false
123
+
124
+ /** Latest flattened active-route polyline + step list (for dev walkers + maneuver math). */
125
+ private var activePolyline: List<Pair<Double, Double>>? = null
126
+ private var activeSteps: List<RouteStep>? = null
127
+ /** Trip totals freshened on every RouteProgress tick. -1 = unknown. */
128
+ private var distanceToDestinationMeters: Int = -1
129
+ private var timeToDestinationSeconds: Int = -1
130
+ /** Current step maneuver + distance-to-next-maneuver from RouteProgress. Null until first tick. */
131
+ private var currentManeuverType: String? = null
132
+ private var distanceToManeuverMeters: Int? = null
133
+ private var currentRoad: String? = null
134
+ private var nextStepRoad: String? = null
135
+
136
+ // ---- Dev toggles (parity with the Google implementation) ----
137
+
138
+ /**
139
+ * Dev toggle: when true, every emitted location is shifted ~8m to the
140
+ * right of the local route bearing before being reported. Simulates a
141
+ * pedestrian walking on the wrong sidewalk so we can verify the
142
+ * along-path pivot trigger fires even when they're never within the
143
+ * 7m point radius of a pivot. Only meaningful in simulate mode.
144
+ */
145
+ private var wrongSidewalkOffsetEnabled: Boolean = false
146
+ private val WRONG_SIDEWALK_OFFSET_M = 8.0
147
+
148
+ /**
149
+ * Dev toggle: when true, takes over from the Mapbox replayer and walks
150
+ * the user along a *modified* polyline that omits crossing micro-steps,
151
+ * reproducing the wrong-sidewalk-then-missed-the-turn scenario. Only
152
+ * takes effect with simulate=true.
153
+ */
154
+ private var skipCrossingsEnabled: Boolean = false
155
+ private var skipCrossingsTimer: java.util.Timer? = null
156
+ private val SKIP_CROSSINGS_BASE_M_PER_TICK = 0.56
157
+ private val SKIP_CROSSINGS_TICK_MS = 400L
158
+ private val CROSSING_MAX_LEG_METERS = 25.0
159
+ private val CROSSING_MIN_BEND_DEG = 60.0
160
+
161
+ /**
162
+ * Baseline walking speed (m/s) stamped onto synthetic replay locations
163
+ * pushed by the dev helpers (deviate jump, skip-crossings walker).
164
+ */
165
+ private val DEVIATE_BASE_MPS = 1.4
166
+ /**
167
+ * How far (meters) the Deviate dev button jumps the simulated puck off
168
+ * the active route. Far enough that Mapbox's off-route detector fires
169
+ * reliably and triggers an automatic reroute.
170
+ */
171
+ private val DEVIATE_OFFSET_M = 60.0
172
+ /** Cached options so a re-request to the same final stop is possible. */
173
+ private var activeOptions: StartOptions? = null
174
+
175
+ /**
176
+ * One-shot gate set in `start()` and fired by `handleLocation` on the
177
+ * FIRST location fix after the trip session starts. Carries (originLat,
178
+ * originLng) — the real device position — into the deferred route
179
+ * request. Cleared after firing (and on stop()) so it runs exactly once
180
+ * per trip. This is how we get a valid origin without a synchronous
181
+ * Mapbox location getter (there is none).
182
+ */
183
+ private var pendingRouteRequest: ((originLat: Double, originLng: Double) -> Unit)? = null
184
+
185
+ // ---- Mapbox replay/sim plumbing ----
186
+ // ReplayRouteSession is Mapbox's purpose-built, all-in-one simulation
187
+ // driver (a MapboxNavigationObserver). It internally observes routes +
188
+ // route progress, pushes the real first device location, generates replay
189
+ // events from the active route, and resets on reroute — the entire sim
190
+ // lifecycle, in the correct order. We attach/detach it instead of hand-
191
+ // rolling MapboxReplayer + ReplayProgressObserver + manual pushEvents
192
+ // (which fought each other and left the puck frozen).
193
+ private var replaySession: ReplayRouteSession? = null
194
+ // The built-in MapboxReplayer is still used by the dev walkers
195
+ // (deviate / skip-crossings), which push synthetic locations directly.
196
+ private var replayer: MapboxReplayer? = null
197
+ private val replayRouteMapper = ReplayRouteMapper()
198
+
199
+ // ---- Mapbox observers (held so we can detach on stop) ----
200
+ private var routesObserver: RoutesObserver? = null
201
+ private var routeProgressObserver: RouteProgressObserver? = null
202
+ private var locationObserver: LocationObserver? = null
203
+ private var offRouteObserver: OffRouteObserver? = null
204
+ private var rerouteStateObserver: RerouteController.RerouteStateObserver? = null
205
+
206
+ // =====================================================================
207
+ // Wire payloads — IDENTICAL to the Google implementation. Do not change
208
+ // field names/types: CrustModule + the bridge depend on this shape.
209
+ // =====================================================================
210
+
211
+ data class ManeuverPayload(
212
+ /**
213
+ * Categorical type of the upcoming maneuver. One of: STRAIGHT,
214
+ * CONTINUE, SLIGHT_LEFT, SLIGHT_RIGHT, TURN_LEFT, TURN_RIGHT,
215
+ * SHARP_LEFT, SHARP_RIGHT, U_TURN, NAME_CHANGE, DEPART, ARRIVE.
216
+ */
217
+ val maneuverType: String,
218
+ /** Meters from the user's current position to that maneuver. -1 if unknown. */
219
+ val distanceMeters: Int,
220
+ /** Road the user is currently on. Null if unavailable. */
221
+ val fromRoad: String?,
222
+ /** Legacy "next road" field (== fromRoad historically). Kept for back-compat. */
223
+ val toRoad: String?,
224
+ /** Road the user will be on AFTER the upcoming maneuver. Null until known. */
225
+ val nextStepRoad: String?,
226
+ /** Total remaining distance to final destination, meters. -1 if unknown. */
227
+ val distanceToDestinationMeters: Int = -1,
228
+ /** Total remaining travel time, seconds. -1 if unknown. */
229
+ val timeToDestinationSeconds: Int = -1,
230
+ /** Current speed in m/s. Null if unavailable. */
231
+ val currentSpeedMps: Float? = null,
232
+ /** Speed limit on the current road segment in m/s. Null if unknown. */
233
+ val speedLimitMps: Float? = null,
234
+ /** Bearing along the route at the user's current position, 0–360. Null if unknown. */
235
+ val routeHeadingDeg: Float? = null,
236
+ /**
237
+ * Mapbox's verbatim turn-by-turn instruction for the step the user is
238
+ * approaching (e.g. "Keep right at the fork.", "Turn left onto Waller
239
+ * Street.", "Your destination is on the right."). Taken from the
240
+ * upcoming step's StepManeuver.instruction(); the maneuver card shows
241
+ * it as-is rather than reconstructing "Turn left onto X". Null when the
242
+ * SDK hasn't supplied one yet.
243
+ */
244
+ val instruction: String? = null,
245
+ )
246
+
247
+ data class LocationPayload(
248
+ val lat: Double,
249
+ val lng: Double,
250
+ val accuracy: Float?,
251
+ val timestamp: Long,
252
+ )
253
+
254
+ data class RoutePoint(val lat: Double, val lng: Double)
255
+
256
+ /**
257
+ * One step along the active route — used by the SDK's pivot module to
258
+ * enrich pivots with `fromRoad` / `toRoad` metadata. Lat/lng is the
259
+ * step's START coordinate.
260
+ */
261
+ data class RouteStep(
262
+ val lat: Double,
263
+ val lng: Double,
264
+ val routeIndex: Int,
265
+ val road: String?,
266
+ val maneuver: String,
267
+ val distanceMeters: Int,
268
+ )
269
+
270
+ interface Callbacks {
271
+ fun onManeuver(payload: ManeuverPayload)
272
+ fun onRerouting()
273
+ fun onArrived()
274
+ fun onError(message: String)
275
+ fun onLocation(payload: LocationPayload)
276
+ fun onRoute(points: List<RoutePoint>, steps: List<RouteStep>?)
277
+ /**
278
+ * Fires once when the user crosses the off-route threshold. Always
279
+ * arrives before `onRerouting` for the same deviation.
280
+ */
281
+ fun onOffRoute(perpendicularDistanceMeters: Double)
282
+ }
283
+
284
+ /** Trip configuration. `stops` is the canonical destination list (last is final). */
285
+ data class StartOptions(
286
+ val stops: List<Pair<Double, Double>>,
287
+ val mode: String = "driving",
288
+ val avoidHighways: Boolean = false,
289
+ val avoidTolls: Boolean = false,
290
+ val avoidFerries: Boolean = false,
291
+ val simulate: Boolean = false,
292
+ // Real-time (1×) by default. Mapbox's ReplayRouteMapper already bakes
293
+ // realistic per-segment speeds into the replay events; `playbackSpeed`
294
+ // is a raw time-scale ON TOP of that, so the old 5× default ran a
295
+ // walking sim at ~7 m/s (a sprint). 1× walks/drives the route at true
296
+ // speed; callers can still pass a higher multiplier to fast-forward.
297
+ val speedMultiplier: Float = 1f,
298
+ )
299
+
300
+ // =====================================================================
301
+ // T&C — Mapbox has no runtime terms dialog. Preserve the callback shape
302
+ // so the miniapp's requestPermission() keeps resolving {accepted:true}.
303
+ // =====================================================================
304
+
305
+ /**
306
+ * Mapbox has no Terms & Conditions dialog (acceptance is by
307
+ * account/usage, not a runtime screen). Resolves immediately `true`.
308
+ * Kept so callers that pre-trigger T&C don't break.
309
+ */
310
+ fun ensureTermsAccepted(activity: Activity, onResult: (accepted: Boolean) -> Unit) {
311
+ onResult(true)
312
+ }
313
+
314
+ /** No-op under Mapbox — there is no terms cache to clear. */
315
+ fun resetTermsAccepted(activity: Activity) {
316
+ Log.d(TAG, "resetTermsAccepted — no-op (Mapbox has no T&C dialog)")
317
+ }
318
+
319
+ // =====================================================================
320
+ // Lifecycle
321
+ // =====================================================================
322
+
323
+ /**
324
+ * Lazily create (or reuse) the process-wide MapboxNavigation. The token
325
+ * is read from the AndroidManifest meta-data `com.mapbox.token` (injected
326
+ * by mobile/plugins/android.ts), mirroring how the Google geo key was
327
+ * provisioned. MapboxNavigationProvider keeps a singleton; we attach our
328
+ * own observers per trip and detach in stop().
329
+ */
330
+ private fun ensureNavigation(activity: Activity): MapboxNavigation {
331
+ appContext = activity.applicationContext
332
+ val existing = if (MapboxNavigationProvider.isCreated()) MapboxNavigationProvider.retrieve() else null
333
+ if (existing != null) {
334
+ mapboxNavigation = existing
335
+ return existing
336
+ }
337
+ // v3 sets the token globally via MapboxOptions, not on NavigationOptions.
338
+ val token = readMapboxToken(activity)
339
+ if (token.isNotBlank()) {
340
+ MapboxOptions.accessToken = token
341
+ } else {
342
+ Log.e(TAG, "com.mapbox.token meta-data is empty — navigation will fail to authenticate")
343
+ }
344
+ val nav = MapboxNavigationProvider.create(
345
+ NavigationOptions.Builder(activity.applicationContext).build(),
346
+ )
347
+ mapboxNavigation = nav
348
+ return nav
349
+ }
350
+
351
+ /** Read the Mapbox runtime token from AndroidManifest meta-data com.mapbox.token. */
352
+ private fun readMapboxToken(activity: Activity): String {
353
+ return try {
354
+ val ai = activity.packageManager.getApplicationInfo(
355
+ activity.packageName,
356
+ android.content.pm.PackageManager.GET_META_DATA,
357
+ )
358
+ ai.metaData?.getString("com.mapbox.token") ?: ""
359
+ } catch (e: Throwable) {
360
+ Log.e(TAG, "failed to read com.mapbox.token meta-data", e)
361
+ ""
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Start a navigation session. Initializes MapboxNavigation on first
367
+ * call. Because Mapbox has no T&C gate, this goes straight into route
368
+ * request + trip start (the `ensureTermsAccepted` wrapper resolves
369
+ * immediately for callers that still invoke it up front).
370
+ */
371
+ fun start(activity: Activity, options: StartOptions, callbacks: Callbacks) {
372
+ Log.d(TAG, "start stops=${options.stops.size} mode=${options.mode} simulate=${options.simulate} speed=${options.speedMultiplier}")
373
+ if (options.stops.isEmpty()) {
374
+ callbacks.onError("at least one stop is required")
375
+ return
376
+ }
377
+ val nav = try {
378
+ ensureNavigation(activity)
379
+ } catch (e: Throwable) {
380
+ Log.e(TAG, "MapboxNavigation init failed", e)
381
+ callbacks.onError("navigation init failed: ${e.message}")
382
+ return
383
+ }
384
+
385
+ activeCallbacks = callbacks
386
+ activeOptions = options
387
+ simulating = options.simulate
388
+ simulationSpeed = options.speedMultiplier.coerceIn(0.5f, 50f)
389
+ offRouteFired = false
390
+ arrivedHandled = false
391
+
392
+ attachObservers(nav, callbacks)
393
+
394
+ // Origin handling — the crux of a correct trip start.
395
+ //
396
+ // Mapbox has NO synchronous "current location" getter: the device
397
+ // position is only delivered asynchronously through LocationObserver,
398
+ // and only AFTER the trip session has started. So we cannot build the
399
+ // route's origin up front. Requesting with `stops.first()` as origin
400
+ // would be a bug — for a single-destination trip that first stop IS the
401
+ // destination, producing a degenerate origin==destination route (the
402
+ // exact trap the SDK's navigation.ts documents avoiding).
403
+ //
404
+ // Correct flow: start the trip session FIRST so location starts
405
+ // flowing, capture the first fix via a one-shot gate, then request the
406
+ // route from that real origin. `pendingRouteRequest` holds the trip
407
+ // params until the gate fires; `handleLocation` invokes it on the first
408
+ // location and clears it.
409
+ pendingRouteRequest = { originLat, originLng ->
410
+ requestAndStartRoute(nav, activity, options, originLat, originLng, callbacks)
411
+ }
412
+ startTripSession(nav, options)
413
+ }
414
+
415
+ /**
416
+ * Build the RouteOptions from a resolved origin + the trip stops, request
417
+ * the route, set it on the navigator, and emit it. Called once the first
418
+ * location fix has arrived (via `pendingRouteRequest`).
419
+ */
420
+ private fun requestAndStartRoute(
421
+ nav: MapboxNavigation,
422
+ activity: Activity,
423
+ options: StartOptions,
424
+ originLat: Double,
425
+ originLng: Double,
426
+ callbacks: Callbacks,
427
+ ) {
428
+ val coordinates = ArrayList<Point>()
429
+ coordinates.add(Point.fromLngLat(originLng, originLat))
430
+ for ((lat, lng) in options.stops) coordinates.add(Point.fromLngLat(lng, lat))
431
+
432
+ val routeOptions = RouteOptions.builder()
433
+ .applyDefaultNavigationOptions(profileFor(options.mode))
434
+ .applyLanguageAndVoiceUnitOptions(activity)
435
+ .coordinatesList(coordinates)
436
+ .alternatives(false)
437
+ .steps(true)
438
+ .bannerInstructions(true)
439
+ .voiceInstructions(false)
440
+ .exclude(buildExclude(options))
441
+ .build()
442
+
443
+ nav.requestRoutes(
444
+ routeOptions,
445
+ object : NavigationRouterCallback {
446
+ override fun onRoutesReady(routes: List<NavigationRoute>, routerOrigin: String) {
447
+ if (routes.isEmpty()) {
448
+ callbacks.onError("no route found")
449
+ return
450
+ }
451
+ nav.setNavigationRoutes(routes)
452
+ // In sim mode the ReplayRouteSession observes setNavigationRoutes
453
+ // and starts driving the puck automatically — no manual replay
454
+ // kick needed here.
455
+ emitRoute(routes.first(), callbacks)
456
+ }
457
+
458
+ override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
459
+ val msg = reasons.firstOrNull()?.message ?: "route request failed"
460
+ Log.e(TAG, "requestRoutes failed: $msg")
461
+ callbacks.onError(msg)
462
+ }
463
+
464
+ override fun onCanceled(routeOptions: RouteOptions, routerOrigin: String) {
465
+ Log.w(TAG, "route request canceled")
466
+ }
467
+ },
468
+ )
469
+ }
470
+
471
+ /** Comma-joined Directions `exclude` list from the avoid flags. Null when none. */
472
+ private fun buildExclude(options: StartOptions): String? {
473
+ val ex = mutableListOf<String>()
474
+ if (options.avoidTolls) ex.add(DirectionsCriteria.EXCLUDE_TOLL)
475
+ if (options.avoidFerries) ex.add(DirectionsCriteria.EXCLUDE_FERRY)
476
+ if (options.avoidHighways) ex.add(DirectionsCriteria.EXCLUDE_MOTORWAY)
477
+ return if (ex.isEmpty()) null else ex.joinToString(",")
478
+ }
479
+
480
+ /**
481
+ * Map the SDK-agnostic mode string to a Mapbox Directions profile.
482
+ * Google's `two_wheeler` has no Mapbox equivalent → driving (see the
483
+ * migration doc's open decision #3).
484
+ */
485
+ private fun profileFor(mode: String): String = when (mode.lowercase()) {
486
+ "walking" -> DirectionsCriteria.PROFILE_WALKING
487
+ "cycling" -> DirectionsCriteria.PROFILE_CYCLING
488
+ "two_wheeler" -> DirectionsCriteria.PROFILE_DRIVING
489
+ "driving" -> DirectionsCriteria.PROFILE_DRIVING_TRAFFIC
490
+ else -> DirectionsCriteria.PROFILE_DRIVING_TRAFFIC
491
+ }
492
+
493
+ /** Begin the trip session — either live GPS or the replay session for simulate mode. */
494
+ @OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
495
+ private fun startTripSession(nav: MapboxNavigation, options: StartOptions) {
496
+ if (options.simulate) {
497
+ // Start the replay trip session, then attach Mapbox's ReplayRouteSession
498
+ // which owns the whole sim: it pushes the real first device location
499
+ // (firing LocationObserver → satisfies our first-fix gate so the route
500
+ // can be requested), then once the route lands it auto-generates replay
501
+ // events and drives the puck — and resets cleanly on every reroute. No
502
+ // manual pushRealLocation / pushEvents / restart needed.
503
+ val session = ReplayRouteSession().setOptions(
504
+ ReplayRouteSessionOptions.Builder()
505
+ .replayRouteOptions(replayRouteOptionsFor(options.mode, simulationSpeed))
506
+ // Snap the replay puck to the freshly-set route after a reroute
507
+ // so re-attaching the session (e.g. after the Deviate dev button
508
+ // forces a divergence) resumes cleanly on the NEW route.
509
+ .locationResetEnabled(true)
510
+ .build(),
511
+ )
512
+ replaySession = session
513
+ replayer = nav.mapboxReplayer
514
+ nav.startReplayTripSession()
515
+ session.onAttached(nav)
516
+ } else {
517
+ nav.startTripSession()
518
+ }
519
+ }
520
+
521
+ /**
522
+ * After a SIMULATED trip arrives, the replay puck is parked at the sim
523
+ * destination — so the location stream is reporting the fake endpoint,
524
+ * not the device's real position. Hand the location source back to real
525
+ * GPS: detach the replay session (its onDetached pushes the real device
526
+ * location) and switch from the replay trip session to a live one, so
527
+ * `onLocation` resumes emitting the user's actual position and the idle
528
+ * map snaps back to reality.
529
+ *
530
+ * No-op on real (non-sim) trips. Best-effort — failures are logged and
531
+ * leave the trip stopped rather than crashing.
532
+ */
533
+ @OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
534
+ private fun returnToRealLocationAfterSimArrival() {
535
+ val nav = mapboxNavigation ?: return
536
+ try {
537
+ replaySession?.onDetached(nav)
538
+ replaySession = null
539
+ // Drop the finished route and flip the session from replay → live
540
+ // GPS. startTripSession() begins consuming the real device location
541
+ // provider again, so the next onLocation fix is the user's true spot.
542
+ nav.setNavigationRoutes(emptyList())
543
+ nav.stopTripSession()
544
+ nav.startTripSession()
545
+ Log.d(TAG, "sim arrival — handed location source back to real GPS")
546
+ } catch (e: Throwable) {
547
+ Log.w(TAG, "returnToRealLocationAfterSimArrival failed: ${e.message}")
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Build ReplayRouteOptions tuned to the travel MODE and a speed
553
+ * multiplier.
554
+ *
555
+ * CRITICAL: Mapbox's default `ReplayRouteOptions` is tuned for DRIVING —
556
+ * `maxSpeedMps = 30` (108 km/h). Using it for a walking trip makes the
557
+ * sim sprint at highway speed (~10 m per tick), which is the "goes much
558
+ * faster than 1×" bug. So we set a realistic cruise speed per mode:
559
+ * walking ≈ 1.4 m/s, cycling ≈ 5 m/s, driving = Mapbox default.
560
+ * `multiplier` scales that cruise (and turn) speed for fast-forward;
561
+ * 1× = real time.
562
+ */
563
+ private fun replayRouteOptionsFor(mode: String, multiplier: Float): ReplayRouteOptions {
564
+ val m = multiplier.toDouble().coerceIn(0.25, 50.0)
565
+ val base = ReplayRouteOptions.Builder().build()
566
+ // Realistic cruise speed (m/s) for the mode. driving keeps Mapbox's
567
+ // default (already realistic for roads).
568
+ val cruiseMps = when (mode.lowercase()) {
569
+ "walking" -> 1.4
570
+ "cycling" -> 5.0
571
+ "two_wheeler" -> base.maxSpeedMps
572
+ "driving" -> base.maxSpeedMps
573
+ else -> base.maxSpeedMps
574
+ }
575
+ // Turn/u-turn speeds: keep below cruise so corners slow down, but never
576
+ // above the mode cruise (else a walking sim still rounds corners at the
577
+ // driving default of 3 m/s — faster than its 1.4 m/s straightaways).
578
+ val turnMps = minOf(base.turnSpeedMps, cruiseMps)
579
+ val uTurnMps = minOf(base.uTurnSpeedMps, cruiseMps)
580
+ return base.toBuilder()
581
+ .maxSpeedMps(cruiseMps * m)
582
+ .turnSpeedMps(turnMps * m)
583
+ .uTurnSpeedMps(uTurnMps * m)
584
+ .build()
585
+ }
586
+
587
+ fun stop() {
588
+ Log.d(TAG, "stop")
589
+ val nav = mapboxNavigation
590
+ activeCallbacks = null
591
+ lastEmittedKey = null
592
+ simulating = false
593
+ simulationSpeed = 1f
594
+
595
+ // Tear down dev walkers first.
596
+ skipCrossingsEnabled = false
597
+ skipCrossingsTimer?.cancel(); skipCrossingsTimer = null
598
+ wrongSidewalkOffsetEnabled = false
599
+
600
+ if (nav != null) {
601
+ try {
602
+ detachObservers(nav)
603
+ // Detach the replay session (stops the sim, unregisters its
604
+ // internal observers, resets the replayer).
605
+ replaySession?.onDetached(nav)
606
+ replayer?.finish()
607
+ nav.stopTripSession()
608
+ nav.setNavigationRoutes(emptyList())
609
+ } catch (e: Exception) {
610
+ Log.e(TAG, "stop failed", e)
611
+ }
612
+ }
613
+ replaySession = null
614
+ replayer = null
615
+
616
+ activePolyline = null
617
+ activeSteps = null
618
+ distanceToDestinationMeters = -1
619
+ timeToDestinationSeconds = -1
620
+ currentManeuverType = null
621
+ distanceToManeuverMeters = null
622
+ currentRoad = null
623
+ nextStepRoad = null
624
+ lastSpeedMps = null
625
+ offRouteFired = false
626
+ activeOptions = null
627
+ pendingRouteRequest = null
628
+ prevFixLat = Double.NaN
629
+ prevFixLng = Double.NaN
630
+ lastFixLat = Double.NaN
631
+ lastFixLng = Double.NaN
632
+ }
633
+
634
+ // =====================================================================
635
+ // Observers — translate Mapbox events into the existing Callbacks shape.
636
+ // =====================================================================
637
+
638
+ private fun attachObservers(nav: MapboxNavigation, callbacks: Callbacks) {
639
+ // REROUTING IS FULLY OWNED BY MAPBOX. Auto-reroute is ON by default in
640
+ // v3 — when the user diverges, the SDK detects off-route AND fetches a
641
+ // new route on its own. We do NOT hand-roll perpendicular-distance
642
+ // detection, and we do NOT re-request the route ourselves; we just
643
+ // observe and forward the SDK's signals:
644
+ // - OffRouteObserver → onOffRoute (purely observational)
645
+ // - RerouteStateObserver → onRerouting (FetchingRoute state)
646
+ // - RoutesObserver(REROUTE) → onRoute (the new route, already active)
647
+
648
+ // RoutesObserver — fires on initial route, every reroute, refresh, and
649
+ // alternatives. The new route is ALREADY set active by the SDK; we just
650
+ // flatten + emit it. We branch on `reason` so a reroute emit can also
651
+ // clear our off-route flag and (for the host) reads as a fresh route.
652
+ val routesObs = RoutesObserver { result: RoutesUpdatedResult ->
653
+ val route = result.navigationRoutes.firstOrNull() ?: return@RoutesObserver
654
+ val reason = result.reason
655
+ // Ignore refresh (same geometry, just live traffic) — it would
656
+ // needlessly re-emit the whole polyline + rebuild pivots.
657
+ if (reason == RoutesExtra.ROUTES_UPDATE_REASON_REFRESH) return@RoutesObserver
658
+ if (reason == RoutesExtra.ROUTES_UPDATE_REASON_REROUTE) {
659
+ Log.d(TAG, "routes updated: REROUTE — emitting new route")
660
+ offRouteFired = false // back on (a fresh) route
661
+ }
662
+ emitRoute(route, callbacks)
663
+ }
664
+ routesObserver = routesObs
665
+ nav.registerRoutesObserver(routesObs)
666
+
667
+ // RouteProgressObserver — every location tick. Carries current step,
668
+ // upcoming maneuver, distance-to-maneuver, and trip totals.
669
+ val progressObs = RouteProgressObserver { progress: RouteProgress ->
670
+ handleRouteProgress(progress, callbacks)
671
+ }
672
+ routeProgressObserver = progressObs
673
+ nav.registerRouteProgressObserver(progressObs)
674
+
675
+ // LocationObserver — enhanced (map-matched) location replaces Google's
676
+ // RoadSnappedLocationProvider.
677
+ val locObs = object : LocationObserver {
678
+ override fun onNewRawLocation(rawLocation: Location) { /* prefer matched */ }
679
+ override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
680
+ handleLocation(result, callbacks)
681
+ }
682
+ }
683
+ locationObserver = locObs
684
+ nav.registerLocationObserver(locObs)
685
+
686
+ // OffRouteObserver — Mapbox's automatic off-route edge signal. PURELY
687
+ // OBSERVATIONAL: registering it does NOT disable auto-reroute. We
688
+ // surface onOffRoute for the UI's "off route" banner; the SDK handles
689
+ // the actual reroute and the new route arrives via RoutesObserver.
690
+ val offRouteObs = OffRouteObserver { offRoute: Boolean ->
691
+ if (offRoute && !offRouteFired) {
692
+ offRouteFired = true
693
+ // Distance from the route is best-effort context for the banner;
694
+ // not used to decide anything (the SDK already decided).
695
+ val perp = currentPerpDistanceMeters() ?: 0.0
696
+ Log.d(TAG, "off-route (Mapbox) — perp≈${perp}m; SDK will auto-reroute")
697
+ callbacks.onOffRoute(perp)
698
+ }
699
+ }
700
+ offRouteObserver = offRouteObs
701
+ nav.registerOffRouteObserver(offRouteObs)
702
+
703
+ // RerouteStateObserver — the authoritative "we are rerouting" signal.
704
+ // FetchingRoute → onRerouting (UI shows the rerouting state); the new
705
+ // route then lands via RoutesObserver(REROUTE). Available only while
706
+ // auto-reroute is enabled (getRerouteController() is null otherwise).
707
+ val rerouteObs = RerouteController.RerouteStateObserver { state: RerouteState ->
708
+ when (state) {
709
+ is RerouteState.FetchingRoute -> {
710
+ Log.d(TAG, "reroute state: FetchingRoute → onRerouting")
711
+ callbacks.onRerouting()
712
+ }
713
+ is RerouteState.Failed -> {
714
+ Log.w(TAG, "reroute failed: ${state.message}")
715
+ // Don't surface as a hard error — the user is still on the old
716
+ // route and the SDK may retry; just log.
717
+ }
718
+ else -> { /* Idle / RouteFetched / Interrupted — no UI change */ }
719
+ }
720
+ }
721
+ rerouteStateObserver = rerouteObs
722
+ nav.getRerouteController()?.registerRerouteStateObserver(rerouteObs)
723
+ }
724
+
725
+ private fun detachObservers(nav: MapboxNavigation) {
726
+ routesObserver?.let { nav.unregisterRoutesObserver(it) }
727
+ routeProgressObserver?.let { nav.unregisterRouteProgressObserver(it) }
728
+ locationObserver?.let { nav.unregisterLocationObserver(it) }
729
+ offRouteObserver?.let { nav.unregisterOffRouteObserver(it) }
730
+ rerouteStateObserver?.let { nav.getRerouteController()?.unregisterRerouteStateObserver(it) }
731
+ routesObserver = null
732
+ routeProgressObserver = null
733
+ locationObserver = null
734
+ offRouteObserver = null
735
+ rerouteStateObserver = null
736
+ }
737
+
738
+ /**
739
+ * Translate a RouteProgress tick into trip-state + an emitted maneuver.
740
+ * Mapbox gives us current step / upcoming maneuver / distance-remaining
741
+ * directly, so no polyline bearing-scanning is needed for the live trip
742
+ * (the bearing helpers remain for the dev walkers).
743
+ */
744
+ private fun handleRouteProgress(progress: RouteProgress, callbacks: Callbacks) {
745
+ distanceToDestinationMeters = progress.distanceRemaining.toInt().coerceAtLeast(-1)
746
+ timeToDestinationSeconds = progress.durationRemaining.toInt().coerceAtLeast(-1)
747
+
748
+ // Arrival.
749
+ if (progress.currentState == RouteProgressState.COMPLETE) {
750
+ callbacks.onArrived()
751
+ // In sim mode the puck is now parked at the SIMULATED destination, so
752
+ // the app's idea of "where I am" is the sim endpoint, not reality.
753
+ // Hand the location source back to real GPS so the idle map snaps to
754
+ // the user's actual position. Guarded by `arrivedHandled` so the
755
+ // repeated COMPLETE ticks the SDK emits after arrival don't reset the
756
+ // session over and over.
757
+ if (simulating && !arrivedHandled) {
758
+ arrivedHandled = true
759
+ returnToRealLocationAfterSimArrival()
760
+ }
761
+ return
762
+ }
763
+
764
+ val legProgress = progress.currentLegProgress
765
+ val stepProgress = legProgress?.currentStepProgress
766
+ val upcomingStep = legProgress?.upcomingStep
767
+ val currentStep = stepProgress?.step
768
+
769
+ // Show the maneuver the user is WALKING TOWARD — i.e. the UPCOMING step's
770
+ // maneuver — together with the distance to it.
771
+ //
772
+ // In Mapbox, `currentStep.maneuver` is the maneuver that BEGAN the
773
+ // current step (the depart, or the turn you just took), so showing it
774
+ // means the card says "Walk on Market St" for the whole approach and
775
+ // only flips to "Turn left" once you're already at the corner — one
776
+ // step behind. The `upcomingStep.maneuver` is the NEXT turn (the one
777
+ // ahead), and `stepProgress.distanceRemaining` is the distance to the
778
+ // END of the current step == where that upcoming maneuver happens. So
779
+ // upcomingStep.maneuver + distanceRemaining is the matched pair that
780
+ // reads "In 120 m, turn left onto the walkway" the whole way in, then
781
+ // "Now" inside the turn radius.
782
+ //
783
+ // The turn-display step (what we show) and its road:
784
+ val turnStep = upcomingStep ?: currentStep // upcoming normally; current only on the final/arrival leg
785
+ val distToManeuver = stepProgress?.distanceRemaining?.toInt() ?: -1
786
+ distanceToManeuverMeters = distToManeuver
787
+
788
+ val maneuver = turnStep?.maneuver()?.let { mapManeuver(it) } ?: "STRAIGHT"
789
+ currentManeuverType = maneuver
790
+ // Road the user is currently on = the current step's road; the road
791
+ // being entered at the turn = the upcoming step's road.
792
+ currentRoad = currentStep?.name()?.takeIf { it.isNotBlank() }
793
+ nextStepRoad = upcomingStep?.name()?.takeIf { it.isNotBlank() }
794
+
795
+ // Verbatim instruction for the maneuver we're showing (the upcoming
796
+ // turn). Same step as `maneuver`/`distToManeuver` so they stay
797
+ // consistent — the instruction describes the turn ahead, not the road
798
+ // we're currently walking.
799
+ val instruction = turnStep?.maneuver()?.instruction()?.takeIf { it.isNotBlank() }
800
+
801
+ val payload = ManeuverPayload(
802
+ maneuverType = maneuver,
803
+ distanceMeters = distToManeuver,
804
+ fromRoad = currentRoad,
805
+ toRoad = currentRoad, // legacy field == fromRoad, matching Google behavior
806
+ nextStepRoad = nextStepRoad,
807
+ distanceToDestinationMeters = distanceToDestinationMeters,
808
+ timeToDestinationSeconds = timeToDestinationSeconds,
809
+ currentSpeedMps = lastSpeedMps,
810
+ routeHeadingDeg = null,
811
+ instruction = instruction,
812
+ )
813
+ emitManeuverIfChanged(payload, callbacks)
814
+ }
815
+
816
+ /**
817
+ * Per-METRE dedup. ReplayRouteSession/RouteProgress ticks ~1 Hz while
818
+ * moving, so a 1 m bucket means the maneuver "In X m" countdown updates
819
+ * roughly once a second and stays in lockstep with the trip
820
+ * distance-to-destination shown on the HUD. (The old 5 m / 10 m buckets
821
+ * made "In 200 m" look frozen for several seconds and let the maneuver
822
+ * distance drift out of agreement with the overall distance on the final
823
+ * leg — e.g. card "In 70 m" while the HUD already said 65 m.)
824
+ *
825
+ * Both distance fields go into the key at 1 m granularity so a change in
826
+ * EITHER re-emits — the card's "In X m" and the HUD's "Arriving in X m"
827
+ * track the same value, to the metre, all the way to the door.
828
+ */
829
+ private fun emitManeuverIfChanged(payload: ManeuverPayload, callbacks: Callbacks) {
830
+ val distBucket = if (payload.distanceMeters >= 0) payload.distanceMeters else -1
831
+ val tripBucket = if (payload.distanceToDestinationMeters >= 0) payload.distanceToDestinationMeters else -1
832
+ val key = "${payload.maneuverType}|$distBucket|$tripBucket"
833
+ // Diagnostic: log EVERY tick (before the dedup early-return) so we can see
834
+ // whether the step distance (top box) is actually moving in lockstep with
835
+ // the trip distance (bottom box), and whether the dedup is suppressing it.
836
+ android.util.Log.d(
837
+ "NavManeuverDist",
838
+ "step=${payload.distanceMeters}m trip=${payload.distanceToDestinationMeters}m type=${payload.maneuverType} key=$key emit=${key != lastEmittedKey}",
839
+ )
840
+ if (key == lastEmittedKey) return
841
+ lastEmittedKey = key
842
+ callbacks.onManeuver(payload)
843
+ }
844
+
845
+ private fun handleLocation(result: LocationMatcherResult, callbacks: Callbacks) {
846
+ val loc = result.enhancedLocation
847
+
848
+ // First-fix gate: if a route request is pending (start() deferred it
849
+ // until we had a real origin), fire it now with the raw device
850
+ // position — NOT any dev-shifted variant. Cleared so it runs once.
851
+ val pending = pendingRouteRequest
852
+ if (pending != null) {
853
+ pendingRouteRequest = null
854
+ pending(loc.latitude, loc.longitude)
855
+ }
856
+
857
+ var effectiveLat = loc.latitude
858
+ var effectiveLng = loc.longitude
859
+
860
+ if (wrongSidewalkOffsetEnabled) {
861
+ val flat = activePolyline
862
+ if (flat != null && flat.size >= 2) {
863
+ val (idx, _) = closestSegmentIndex(flat, effectiveLat, effectiveLng)
864
+ val a = flat[idx]
865
+ val b = flat[(idx + 1).coerceAtMost(flat.size - 1)]
866
+ val routeBearing = bearing(a.first, a.second, b.first, b.second)
867
+ val perpBearing = (routeBearing + 90.0) % 360.0
868
+ val (offLat, offLng) = movePoint(effectiveLat, effectiveLng, WRONG_SIDEWALK_OFFSET_M, perpBearing)
869
+ effectiveLat = offLat
870
+ effectiveLng = offLng
871
+ }
872
+ }
873
+
874
+ prevFixLat = lastFixLat
875
+ prevFixLng = lastFixLng
876
+ lastFixLat = effectiveLat
877
+ lastFixLng = effectiveLng
878
+ lastSpeedMps = loc.speed?.toFloat()
879
+
880
+ callbacks.onLocation(
881
+ LocationPayload(
882
+ lat = effectiveLat,
883
+ lng = effectiveLng,
884
+ accuracy = loc.horizontalAccuracy?.toFloat(),
885
+ timestamp = System.currentTimeMillis(),
886
+ ),
887
+ )
888
+ // Off-route detection is now FULLY owned by Mapbox (OffRouteObserver +
889
+ // automatic reroute). No hand-rolled perpendicular-distance check here.
890
+ }
891
+
892
+ /** Perp distance from the last fix to the active polyline, or null. */
893
+ private fun currentPerpDistanceMeters(): Double? {
894
+ val flat = activePolyline ?: return null
895
+ if (flat.size < 2 || lastFixLat.isNaN() || lastFixLng.isNaN()) return null
896
+ val (_, perp) = closestSegmentIndex(flat, lastFixLat, lastFixLng)
897
+ return perp
898
+ }
899
+
900
+ // =====================================================================
901
+ // Route emit — flatten a NavigationRoute into the wire RoutePoint/RouteStep
902
+ // shape. Mapbox carries steps inline, so unlike Google there's no deferred
903
+ // re-emit waiting on a separate NavInfo service.
904
+ // =====================================================================
905
+
906
+ private fun emitRoute(route: NavigationRoute, callbacks: Callbacks) {
907
+ try {
908
+ val directionsRoute = route.directionsRoute
909
+ val geometry = directionsRoute.geometry()
910
+ val points: List<RoutePoint> = decodeGeometry(route)
911
+ if (points.isEmpty()) {
912
+ Log.w(TAG, "route geometry empty, nothing to emit")
913
+ return
914
+ }
915
+ val steps = buildRouteSteps(directionsRoute, points)
916
+ activePolyline = points.map { it.lat to it.lng }
917
+ activeSteps = steps
918
+ Log.d(TAG, "emit route — ${points.size} points, steps=${steps?.size ?: "null"}")
919
+ callbacks.onRoute(points, steps)
920
+ } catch (e: Exception) {
921
+ Log.e(TAG, "emitRoute failed", e)
922
+ }
923
+ }
924
+
925
+ /** Decode the route polyline. NavigationRoute exposes the full geometry. */
926
+ private fun decodeGeometry(route: NavigationRoute): List<RoutePoint> {
927
+ val out = ArrayList<RoutePoint>()
928
+ // The directions route geometry honors the request's geometry
929
+ // precision; NavigationRoute also exposes per-step geometry. Walk the
930
+ // legs' steps' geometry to assemble the full polyline in order.
931
+ val legs = route.directionsRoute.legs() ?: return out
932
+ for (leg in legs) {
933
+ val steps = leg.steps() ?: continue
934
+ for (step in steps) {
935
+ val geom = step.geometry() ?: continue
936
+ val pts = com.mapbox.geojson.utils.PolylineUtils.decode(geom, 6)
937
+ for (p in pts) out.add(RoutePoint(p.latitude(), p.longitude()))
938
+ }
939
+ }
940
+ return out
941
+ }
942
+
943
+ /**
944
+ * Build the wire-shape step list from the Directions legs/steps. Unlike
945
+ * Google's StepInfo (no coordinates), Mapbox `LegStep` carries its own
946
+ * geometry, so each step's start vertex is exact — no polyline-walking
947
+ * by cumulative distance needed.
948
+ */
949
+ private fun buildRouteSteps(route: DirectionsRoute, points: List<RoutePoint>): List<RouteStep>? {
950
+ val legs = route.legs() ?: return null
951
+ val out = ArrayList<RouteStep>()
952
+ var runningIndex = 0
953
+ for (leg in legs) {
954
+ val steps = leg.steps() ?: continue
955
+ for (step in steps) {
956
+ val maneuver = step.maneuver()?.let { mapManeuver(it) } ?: "STRAIGHT"
957
+ val road = step.name()?.takeIf { it.isNotBlank() }
958
+ val loc = step.maneuver()?.location()
959
+ val lat = loc?.latitude() ?: continue
960
+ val lng = loc.longitude()
961
+ // routeIndex: nearest polyline vertex to this step's maneuver point.
962
+ val idx = nearestPointIndex(points, lat, lng, runningIndex)
963
+ runningIndex = idx
964
+ out.add(
965
+ RouteStep(
966
+ lat = lat,
967
+ lng = lng,
968
+ routeIndex = idx,
969
+ road = road,
970
+ maneuver = maneuver,
971
+ distanceMeters = step.distance().toInt(),
972
+ ),
973
+ )
974
+ }
975
+ }
976
+ return if (out.isEmpty()) null else out
977
+ }
978
+
979
+ /** Nearest polyline vertex to (lat,lng), scanning forward from `from`. */
980
+ private fun nearestPointIndex(points: List<RoutePoint>, lat: Double, lng: Double, from: Int): Int {
981
+ var best = from.coerceIn(0, points.size - 1)
982
+ var bestD = Double.MAX_VALUE
983
+ for (i in from until points.size) {
984
+ val dLat = points[i].lat - lat
985
+ val dLng = points[i].lng - lng
986
+ val d = dLat * dLat + dLng * dLng
987
+ if (d < bestD) { bestD = d; best = i }
988
+ }
989
+ return best
990
+ }
991
+
992
+ // =====================================================================
993
+ // Maneuver mapping — Mapbox `type` + `modifier` → our ManeuverKind union.
994
+ // (Replaces NavInfoReceiverService.mapManeuver's Google-enum table.)
995
+ // =====================================================================
996
+
997
+ /**
998
+ * Reduce a Mapbox StepManeuver (type + modifier) to the categorical
999
+ * string vocabulary the bridge already uses: STRAIGHT, CONTINUE,
1000
+ * SLIGHT_LEFT, SLIGHT_RIGHT, TURN_LEFT, TURN_RIGHT, SHARP_LEFT,
1001
+ * SHARP_RIGHT, U_TURN, NAME_CHANGE, DEPART, ARRIVE.
1002
+ *
1003
+ * Per the migration doc §7. The PivotEngine trusts polyline geometry
1004
+ * over this string for left/right, so roundabout approximations degrade
1005
+ * gracefully.
1006
+ */
1007
+ @JvmStatic
1008
+ fun mapManeuver(maneuver: StepManeuver): String {
1009
+ val type = maneuver.type()
1010
+ val modifier = maneuver.modifier()
1011
+ return when (type) {
1012
+ StepManeuver.DEPART -> "DEPART"
1013
+ StepManeuver.ARRIVE -> "ARRIVE"
1014
+ StepManeuver.NEW_NAME -> "NAME_CHANGE"
1015
+ StepManeuver.CONTINUE, StepManeuver.MERGE -> "CONTINUE"
1016
+ StepManeuver.NOTIFICATION -> "STRAIGHT"
1017
+ // turn / end of road / fork / on ramp / off ramp / roundabout* →
1018
+ // classify by modifier.
1019
+ else -> classifyModifier(modifier)
1020
+ }
1021
+ }
1022
+
1023
+ private fun classifyModifier(modifier: String?): String = when (modifier) {
1024
+ ManeuverModifier.UTURN -> "U_TURN"
1025
+ ManeuverModifier.SHARP_LEFT -> "SHARP_LEFT"
1026
+ ManeuverModifier.LEFT -> "TURN_LEFT"
1027
+ ManeuverModifier.SLIGHT_LEFT -> "SLIGHT_LEFT"
1028
+ ManeuverModifier.STRAIGHT -> "STRAIGHT"
1029
+ ManeuverModifier.SLIGHT_RIGHT -> "SLIGHT_RIGHT"
1030
+ ManeuverModifier.RIGHT -> "TURN_RIGHT"
1031
+ ManeuverModifier.SHARP_RIGHT -> "SHARP_RIGHT"
1032
+ else -> "STRAIGHT"
1033
+ }
1034
+
1035
+ // =====================================================================
1036
+ // Dev simulation — full parity with the Google implementation, driven by
1037
+ // MapboxReplayer instead of Google's Navigator.simulator.
1038
+ // =====================================================================
1039
+
1040
+ /**
1041
+ * Dev-only: shove the simulated puck OFF the active route so Mapbox's
1042
+ * built-in off-route detection + automatic rerouting kick in — exactly
1043
+ * like a real GPS divergence. We no longer hand-roll the off-route math
1044
+ * or call requestRoutes ourselves; we just teleport the replay puck
1045
+ * `DEVIATE_OFFSET_M` perpendicular to the route and let the SDK do the
1046
+ * rest (OffRouteObserver → onOffRoute, RerouteState → onRerouting,
1047
+ * RoutesObserver(REROUTE) → onRoute).
1048
+ *
1049
+ * Requires simulate mode (a replayer to inject into). On a real-GPS trip
1050
+ * there's nothing to push, so it no-ops with a log. `offsetMeters` from
1051
+ * the legacy protocol overrides the default jump distance when > 0.
1052
+ */
1053
+ @OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
1054
+ fun simulateDeviation(offsetMeters: Double = 0.0) {
1055
+ val replayer = replayer ?: run {
1056
+ Log.w(TAG, "simulateDeviation: not in simulate mode — nothing to push off-route")
1057
+ return
1058
+ }
1059
+ if (lastFixLat.isNaN() || lastFixLng.isNaN()) {
1060
+ Log.w(TAG, "simulateDeviation: no last fix yet")
1061
+ return
1062
+ }
1063
+
1064
+ val nav = mapboxNavigation ?: run { Log.w(TAG, "simulateDeviation: no navigation"); return }
1065
+
1066
+ // Perpendicular bearing to walk along. Use the route's local bearing at
1067
+ // the puck so we step SIDEWAYS off the road; fall back to the user's
1068
+ // recent travel bearing + 90°.
1069
+ val flat = activePolyline
1070
+ val sideBearing: Double = if (flat != null && flat.size >= 2) {
1071
+ val (idx, _) = closestSegmentIndex(flat, lastFixLat, lastFixLng)
1072
+ val a = flat[idx]
1073
+ val b = flat[(idx + 1).coerceAtMost(flat.size - 1)]
1074
+ (bearing(a.first, a.second, b.first, b.second) + 90.0) % 360.0
1075
+ } else if (!prevFixLat.isNaN() && haversine(prevFixLat, prevFixLng, lastFixLat, lastFixLng) > 0.5) {
1076
+ (bearing(prevFixLat, prevFixLng, lastFixLat, lastFixLng) + 90.0) % 360.0
1077
+ } else {
1078
+ 90.0 // arbitrary — due east
1079
+ }
1080
+
1081
+ val maxJump = if (offsetMeters > 0.0) offsetMeters else DEVIATE_OFFSET_M
1082
+
1083
+ // THE KEY: ReplayRouteSession keeps pushing ON-ROUTE locations every
1084
+ // RouteProgress tick (its internal RouteProgressObserver → pushEvents),
1085
+ // so a one-off off-route blip is immediately buried and the puck never
1086
+ // actually leaves the route. To force a real divergence we must:
1087
+ // 1. detach the session so it stops feeding on-route points,
1088
+ // 2. clear its queued on-route events,
1089
+ // 3. push a SUSTAINED stream walking progressively off-route (so the
1090
+ // divergence persists long enough for Mapbox to detect it),
1091
+ // 4. once the reroute lands (RoutesObserver REROUTE), the session is
1092
+ // re-attached so the puck resumes on the NEW route.
1093
+ try {
1094
+ replaySession?.onDetached(nav)
1095
+ replayer.clearEvents()
1096
+
1097
+ // Walk from the puck outward to maxJump in ~10m steps, each a
1098
+ // separate replay location so the off-route position is held across
1099
+ // several ticks rather than a single frame.
1100
+ //
1101
+ // CRITICAL: the replayer plays events ordered/paced by eventTimestamp.
1102
+ // All-zero timestamps would collapse into a single instant and never
1103
+ // play as a sequence — so we stamp each event with a monotonically
1104
+ // increasing time (1s apart) to hold the off-route position over
1105
+ // several real seconds, which is what lets the detector latch.
1106
+ val baseLat = lastFixLat
1107
+ val baseLng = lastFixLng
1108
+ val events = ArrayList<ReplayEventBase>()
1109
+ var d = 10.0
1110
+ var ts = 0.0
1111
+ while (d <= maxJump) {
1112
+ val (oLat, oLng) = movePoint(baseLat, baseLng, d, sideBearing)
1113
+ events.add(ReplayRouteMapper.mapToUpdateLocation(ts, Point.fromLngLat(oLng, oLat)))
1114
+ d += 10.0
1115
+ ts += 1.0
1116
+ }
1117
+ // Hold at the farthest point for a few extra seconds so the off-route
1118
+ // condition is unambiguous and the detector latches.
1119
+ val (farLat, farLng) = movePoint(baseLat, baseLng, maxJump, sideBearing)
1120
+ val farPoint = Point.fromLngLat(farLng, farLat)
1121
+ repeat(6) {
1122
+ events.add(ReplayRouteMapper.mapToUpdateLocation(ts, farPoint))
1123
+ ts += 1.0
1124
+ }
1125
+
1126
+ Log.d(TAG, "simulateDeviation: detached session, walking ${maxJump}m off-route in ${events.size} events")
1127
+ replayer.pushEvents(events)
1128
+ replayer.play()
1129
+
1130
+ // The divergence above moves the puck off-route (which exercises the
1131
+ // OffRouteObserver / banner path). But to GUARANTEE a reroute even if
1132
+ // the replayed divergence is too brief for the native detector, also
1133
+ // ask the RerouteController to reroute directly — this is the reliable
1134
+ // trigger. It fetches a new route from the current (now off-route)
1135
+ // position and the result flows through RoutesObserver(REROUTE) →
1136
+ // onRoute exactly like an automatic reroute. Fire it slightly after
1137
+ // the off-route events start playing so the controller reroutes from
1138
+ // the diverged position, not the on-route one.
1139
+ val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
1140
+ mainHandler.postDelayed({
1141
+ try {
1142
+ val controller = mapboxNavigation?.getRerouteController()
1143
+ if (controller != null) {
1144
+ // onRerouting fires automatically via our RerouteStateObserver
1145
+ // (FetchingRoute) when reroute() runs — no manual call needed.
1146
+ Log.d(TAG, "simulateDeviation: requesting reroute() directly")
1147
+ controller.reroute(
1148
+ RerouteController.RoutesCallback { routes: List<NavigationRoute>, _: String ->
1149
+ // RoutesObserver(REROUTE) will also fire and emit the route;
1150
+ // we don't setNavigationRoutes here (the controller does).
1151
+ Log.d(TAG, "simulateDeviation: reroute() returned ${routes.size} route(s)")
1152
+ },
1153
+ )
1154
+ } else {
1155
+ Log.w(TAG, "simulateDeviation: no reroute controller (auto-reroute disabled?)")
1156
+ }
1157
+ } catch (e: Throwable) {
1158
+ Log.e(TAG, "simulateDeviation reroute() failed", e)
1159
+ }
1160
+ }, 1500L)
1161
+
1162
+ // Re-attach the session so it resumes driving the puck once a route is
1163
+ // active again (locationResetEnabled snaps it onto the new route).
1164
+ // Delay until AFTER the off-route events + reroute fetch.
1165
+ val playSeconds = (ts / simulationSpeed.coerceAtLeast(0.1f)).toDouble()
1166
+ val reattachMs = ((playSeconds + 4.0) * 1000.0).toLong().coerceIn(5000L, 20000L)
1167
+ mainHandler.postDelayed({
1168
+ try {
1169
+ if (simulating) replaySession?.onAttached(nav)
1170
+ } catch (e: Throwable) {
1171
+ Log.w(TAG, "simulateDeviation: re-attach session failed: ${e.message}")
1172
+ }
1173
+ }, reattachMs)
1174
+ } catch (e: Throwable) {
1175
+ Log.e(TAG, "simulateDeviation push failed", e)
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Dev toggle. Shift every emitted location ~8m perpendicular-right of the
1181
+ * route bearing, simulating a wrong-sidewalk pedestrian.
1182
+ */
1183
+ fun setWrongSidewalkOffset(enabled: Boolean) {
1184
+ Log.d(TAG, "setWrongSidewalkOffset($enabled)")
1185
+ wrongSidewalkOffsetEnabled = enabled
1186
+ }
1187
+
1188
+ /**
1189
+ * Dev toggle. Take over from the replayer and walk a polyline with
1190
+ * crossing micro-steps removed. When disabled, hand control back to the
1191
+ * replayer on the active route.
1192
+ */
1193
+ fun setSkipCrossings(enabled: Boolean) {
1194
+ Log.d(TAG, "setSkipCrossings($enabled)")
1195
+ skipCrossingsEnabled = enabled
1196
+ if (enabled) startSkipCrossingsWalker() else stopSkipCrossingsWalker(resumeReplay = true)
1197
+ }
1198
+
1199
+ private fun startSkipCrossingsWalker() {
1200
+ val flat = activePolyline
1201
+ if (flat == null || flat.size < 2) { Log.w(TAG, "startSkipCrossingsWalker: no route polyline"); return }
1202
+ try { replayer?.stop() } catch (_: Throwable) {}
1203
+ val modified = stripCrossings(flat)
1204
+ Log.d(TAG, "skip-crossings walker: original=${flat.size} pts, modified=${modified.size} pts")
1205
+ stopSkipCrossingsWalker(resumeReplay = false)
1206
+ val startIdx = if (!lastFixLat.isNaN() && !lastFixLng.isNaN()) {
1207
+ closestPolylineIndex(modified, lastFixLat, lastFixLng)
1208
+ } else 0
1209
+ val timer = java.util.Timer("skipCrossingsWalker", true)
1210
+ val stepMeters = SKIP_CROSSINGS_BASE_M_PER_TICK * simulationSpeed
1211
+ var cursor = startIdx.toDouble()
1212
+ val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
1213
+ timer.scheduleAtFixedRate(object : java.util.TimerTask() {
1214
+ override fun run() {
1215
+ mainHandler.post {
1216
+ try {
1217
+ if (!skipCrossingsEnabled) { cancel(); return@post }
1218
+ cursor = advanceCursor(modified, cursor, stepMeters)
1219
+ if (cursor >= modified.size - 1) { cancel(); return@post }
1220
+ val (lat, lng) = pointAt(modified, cursor)
1221
+ val brng = run {
1222
+ val (nlat, nlng) = pointAt(modified, (cursor + 0.5).coerceAtMost(modified.size - 1.0))
1223
+ bearing(lat, lng, nlat, nlng)
1224
+ }
1225
+ pushReplayLocation(lat, lng, brng)
1226
+ activeCallbacks?.onLocation(LocationPayload(lat, lng, null, System.currentTimeMillis()))
1227
+ prevFixLat = lastFixLat; prevFixLng = lastFixLng
1228
+ lastFixLat = lat; lastFixLng = lng
1229
+ } catch (e: Throwable) {
1230
+ Log.e(TAG, "skip-crossings walker tick failed", e)
1231
+ }
1232
+ }
1233
+ }
1234
+ }, 0L, SKIP_CROSSINGS_TICK_MS)
1235
+ skipCrossingsTimer = timer
1236
+ }
1237
+
1238
+ private fun stopSkipCrossingsWalker(resumeReplay: Boolean) {
1239
+ skipCrossingsTimer?.cancel(); skipCrossingsTimer = null
1240
+ if (resumeReplay && simulating) restartReplayOnActiveRoute()
1241
+ }
1242
+
1243
+ /**
1244
+ * (Re)start the replayer driving the current active route at
1245
+ * simulationSpeed. Called once the route lands (start) and on every
1246
+ * reroute.
1247
+ *
1248
+ * Ordering matters and was the source of the "sim sometimes doesn't go
1249
+ * off" bug:
1250
+ * 1. stop() the player first — if it's mid-playback on the old buffer
1251
+ * (or the bootstrap seed location), pushing/clearing under it raced
1252
+ * and play() could no-op.
1253
+ * 2. clearEvents() wipes the seed + any prior route events so the new
1254
+ * route plays from its true start.
1255
+ * 3. set playbackSpeed BEFORE play so the first tick is already at the
1256
+ * right rate.
1257
+ * 4. play() — starts from the head of the freshly-pushed buffer. We do
1258
+ * NOT seekTo(firstEvent): seeking to a specific event object after a
1259
+ * clear was unreliable (it occasionally landed past the start or on
1260
+ * a stale reference), leaving the puck frozen.
1261
+ */
1262
+ private fun restartReplayOnActiveRoute() {
1263
+ val nav = mapboxNavigation ?: return
1264
+ val replayer = replayer ?: return
1265
+ val route = nav.getNavigationRoutes().firstOrNull() ?: return
1266
+ try {
1267
+ val events = replayRouteMapper.mapDirectionsRouteGeometry(route.directionsRoute)
1268
+ if (events.isEmpty()) {
1269
+ Log.w(TAG, "restartReplayOnActiveRoute: no replay events from route geometry")
1270
+ return
1271
+ }
1272
+ replayer.stop()
1273
+ replayer.clearEvents()
1274
+ replayer.pushEvents(events)
1275
+ replayer.playbackSpeed(simulationSpeed.toDouble())
1276
+ replayer.play()
1277
+ Log.d(TAG, "replay restarted — ${events.size} events @ ${simulationSpeed}×")
1278
+ } catch (e: Throwable) {
1279
+ Log.w(TAG, "restartReplayOnActiveRoute failed: ${e.message}")
1280
+ }
1281
+ }
1282
+
1283
+ /** Push one synthetic location into the replayer (used by the dev walkers). */
1284
+ private fun pushReplayLocation(lat: Double, lng: Double, bearingDeg: Double) {
1285
+ val replayer = replayer ?: return
1286
+ // ReplayEventLocation(lon, lat, provider, time, altitude,
1287
+ // accuracyHorizontal, bearing, speed) — positional; nullable fields
1288
+ // are boxed Double?.
1289
+ val location = ReplayEventLocation(
1290
+ lng,
1291
+ lat,
1292
+ "mentra-dev-walker",
1293
+ null,
1294
+ null,
1295
+ null,
1296
+ bearingDeg,
1297
+ DEVIATE_BASE_MPS * simulationSpeed,
1298
+ )
1299
+ val event = ReplayEventUpdateLocation(0.0, location)
1300
+ try {
1301
+ replayer.pushEvents(listOf<ReplayEventBase>(event))
1302
+ replayer.play()
1303
+ } catch (e: Throwable) {
1304
+ Log.w(TAG, "pushReplayLocation failed: ${e.message}")
1305
+ }
1306
+ }
1307
+
1308
+ // ---- crossing strip (unchanged math from the Google implementation) ----
1309
+
1310
+ private fun stripCrossings(points: List<Pair<Double, Double>>): List<Pair<Double, Double>> {
1311
+ if (points.size < 4) return points
1312
+ val out = ArrayList<Pair<Double, Double>>(points.size)
1313
+ out.add(points[0])
1314
+ var i = 1
1315
+ while (i < points.size - 1) {
1316
+ val prev = points[i - 1]; val here = points[i]; val next = points[i + 1]
1317
+ val legOut = haversine(here.first, here.second, next.first, next.second)
1318
+ val bearIn = bearing(prev.first, prev.second, here.first, here.second)
1319
+ val bearOut = bearing(here.first, here.second, next.first, next.second)
1320
+ val bend = bearingDiffAbs(bearIn, bearOut)
1321
+ val looksLikeCrossingStart = legOut < CROSSING_MAX_LEG_METERS && bend > CROSSING_MIN_BEND_DEG
1322
+ if (looksLikeCrossingStart && i + 2 < points.size) {
1323
+ val afterNext = points[i + 2]
1324
+ val bearAfter = bearing(next.first, next.second, afterNext.first, afterNext.second)
1325
+ val bendBack = bearingDiffAbs(bearOut, bearAfter)
1326
+ val isCrossing = bendBack > CROSSING_MIN_BEND_DEG &&
1327
+ bearingDiffAbs(bearIn, bearAfter) < CROSSING_MIN_BEND_DEG
1328
+ if (isCrossing) {
1329
+ val legAfter = haversine(next.first, next.second, afterNext.first, afterNext.second)
1330
+ val skipDist = legOut + legAfter
1331
+ val (newLat, newLng) = movePoint(here.first, here.second, skipDist, bearIn)
1332
+ out.add(Pair(newLat, newLng))
1333
+ i += 3
1334
+ continue
1335
+ }
1336
+ }
1337
+ out.add(here)
1338
+ i++
1339
+ }
1340
+ if (out.last() != points.last()) out.add(points.last())
1341
+ return out
1342
+ }
1343
+
1344
+ private fun bearingDiffAbs(a: Double, b: Double): Double {
1345
+ val d = (a - b + 540.0) % 360.0 - 180.0
1346
+ return kotlin.math.abs(d)
1347
+ }
1348
+
1349
+ private fun closestPolylineIndex(points: List<Pair<Double, Double>>, lat: Double, lng: Double): Int {
1350
+ var best = 0; var bestD = Double.MAX_VALUE
1351
+ for (i in points.indices) {
1352
+ val (plat, plng) = points[i]
1353
+ val dx = plat - lat; val dy = plng - lng
1354
+ val d = dx * dx + dy * dy
1355
+ if (d < bestD) { bestD = d; best = i }
1356
+ }
1357
+ return best
1358
+ }
1359
+
1360
+ private fun advanceCursor(points: List<Pair<Double, Double>>, cursor: Double, meters: Double): Double {
1361
+ if (points.size < 2) return cursor.coerceAtMost(0.0)
1362
+ var idx = cursor.toInt(); var frac = cursor - idx; var remaining = meters
1363
+ while (remaining > 0 && idx < points.size - 1) {
1364
+ val a = points[idx]; val b = points[idx + 1]
1365
+ val segLen = haversine(a.first, a.second, b.first, b.second)
1366
+ val segLeft = (1.0 - frac) * segLen
1367
+ if (remaining < segLeft) { frac += remaining / segLen; remaining = 0.0 }
1368
+ else { remaining -= segLeft; idx++; frac = 0.0 }
1369
+ }
1370
+ return idx.toDouble() + frac
1371
+ }
1372
+
1373
+ private fun pointAt(points: List<Pair<Double, Double>>, cursor: Double): Pair<Double, Double> {
1374
+ val idx = cursor.toInt().coerceIn(0, points.size - 1)
1375
+ val frac = (cursor - idx).coerceIn(0.0, 1.0)
1376
+ if (idx >= points.size - 1) return points.last()
1377
+ val a = points[idx]; val b = points[idx + 1]
1378
+ return Pair(a.first + (b.first - a.first) * frac, a.second + (b.second - a.second) * frac)
1379
+ }
1380
+
1381
+ private fun movePoint(lat: Double, lng: Double, meters: Double, bearingDeg: Double): Pair<Double, Double> {
1382
+ val r = 6_371_000.0
1383
+ val brng = Math.toRadians(bearingDeg)
1384
+ val lat1 = Math.toRadians(lat); val lng1 = Math.toRadians(lng)
1385
+ val ad = meters / r
1386
+ val lat2 = kotlin.math.asin(
1387
+ kotlin.math.sin(lat1) * kotlin.math.cos(ad) +
1388
+ kotlin.math.cos(lat1) * kotlin.math.sin(ad) * kotlin.math.cos(brng),
1389
+ )
1390
+ val lng2 = lng1 + kotlin.math.atan2(
1391
+ kotlin.math.sin(brng) * kotlin.math.sin(ad) * kotlin.math.cos(lat1),
1392
+ kotlin.math.cos(ad) - kotlin.math.sin(lat1) * kotlin.math.sin(lat2),
1393
+ )
1394
+ return Math.toDegrees(lat2) to Math.toDegrees(lng2)
1395
+ }
1396
+
1397
+ // ---- math helpers (unchanged) ----
1398
+
1399
+ private fun haversine(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
1400
+ val r = 6_371_000.0
1401
+ val dLat = Math.toRadians(lat2 - lat1)
1402
+ val dLng = Math.toRadians(lng2 - lng1)
1403
+ val a = kotlin.math.sin(dLat / 2).let { it * it } +
1404
+ kotlin.math.cos(Math.toRadians(lat1)) *
1405
+ kotlin.math.cos(Math.toRadians(lat2)) *
1406
+ kotlin.math.sin(dLng / 2).let { it * it }
1407
+ return 2 * r * kotlin.math.asin(kotlin.math.sqrt(a))
1408
+ }
1409
+
1410
+ private fun bearing(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
1411
+ val phi1 = Math.toRadians(lat1); val phi2 = Math.toRadians(lat2)
1412
+ val lam1 = Math.toRadians(lng1); val lam2 = Math.toRadians(lng2)
1413
+ val y = kotlin.math.sin(lam2 - lam1) * kotlin.math.cos(phi2)
1414
+ val x = kotlin.math.cos(phi1) * kotlin.math.sin(phi2) -
1415
+ kotlin.math.sin(phi1) * kotlin.math.cos(phi2) * kotlin.math.cos(lam2 - lam1)
1416
+ val deg = Math.toDegrees(kotlin.math.atan2(y, x))
1417
+ return (deg + 360.0) % 360.0
1418
+ }
1419
+
1420
+ private fun closestSegmentIndex(
1421
+ pts: List<Pair<Double, Double>>, lat: Double, lng: Double,
1422
+ ): Pair<Int, Double> {
1423
+ var bestIdx = 0; var bestDist = Double.POSITIVE_INFINITY
1424
+ for (i in 0 until pts.size - 1) {
1425
+ val d = perpDistanceMeters(lat, lng, pts[i].first, pts[i].second, pts[i + 1].first, pts[i + 1].second)
1426
+ if (d < bestDist) { bestDist = d; bestIdx = i }
1427
+ }
1428
+ return Pair(bestIdx, bestDist)
1429
+ }
1430
+
1431
+ private fun perpDistanceMeters(
1432
+ pLat: Double, pLng: Double, aLat: Double, aLng: Double, bLat: Double, bLng: Double,
1433
+ ): Double {
1434
+ val mPerDegLat = 111_320.0
1435
+ val mPerDegLng = 111_320.0 * kotlin.math.cos(Math.toRadians(aLat))
1436
+ val px = (pLng - aLng) * mPerDegLng; val py = (pLat - aLat) * mPerDegLat
1437
+ val bx = (bLng - aLng) * mPerDegLng; val by = (bLat - aLat) * mPerDegLat
1438
+ val len2 = bx * bx + by * by
1439
+ if (len2 == 0.0) return kotlin.math.sqrt(px * px + py * py)
1440
+ var t = (px * bx + py * by) / len2
1441
+ t = t.coerceIn(0.0, 1.0)
1442
+ val projx = t * bx; val projy = t * by
1443
+ return kotlin.math.sqrt((px - projx).let { it * it } + (py - projy).let { it * it })
1444
+ }
1445
+ }