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