stadiamaps / stadiamaps/ferrostar
Android: a new session opens at the previous session's last location (restarted simulation arrives instantly)
- Dominant language
- Kotlin
- Stars
- 419
- Forks
- 81
- Avg merge
- 6d 1h
- Merged PRs (30d)
- 7
Description
### Summary
Stopping a navigation session and starting a new one on Android opens the new session at the **previous session's last location**. With `SimulatedLocationProvider` that location is the previous trip's arrival point, so a restarted simulation begins with the user apparently standing at the destination.
Related to #412, but this is not the `FusedLocationProvider.lastLocation` path — it reproduces with the simulated provider and comes from two other places:
1. **`FerrostarCore` keeps `_lastLocation` across sessions.** `stopNavigation()` clears the session, the state, the location job and `_lastRecalculationLocation`, but not `_lastLocation` ([FerrostarCore.kt#L358](https://github.com/stadiamaps/ferrostar/blob/0.53.0/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt#L358)). `startNavigation()` then seeds the opening state with it: `val startingLocation = _lastLocation ?: UserLocation(route.geometry.first(), ...)` ([#L256](https://github.com/stadiamaps/ferrostar/blob/0.53.0/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt#L256), same pattern in `resumeNavigation` and `replaceRoute`). So the very first `TripState.Navigating` a consumer observes carries a location from the previous trip.
2. **`SimulatedLocationProvider` replays its last emission.** `sharedUpdates` is `shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)` ([SimulatedLocationProvider.kt#L69](https://github.com/stadiamaps/ferrostar/blob/0.53.0/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt#L69)). The replay cache survives the upstream restart, so the first thing the new session's collector receives is again the previous trip's final fix. `_lastLocation` in that class is likewise not reset by `setRoute`.
`setRoute` also can't force a restart when the same route is set twice: `_routeFlow` is a `MutableStateFlow` and `locationSimulationFromRoute(...)` produces an equal value, which `StateFlow` conflates. The simulation does restart via the `WhileSubscribed` resubscription, so this is a latent hazard rather than a bug on its own — but it means `setRoute` is not a reliable reset.
### Steps to reproduce
1. Load a route and start navigation with `SimulatedLocationProvider` (via `NavigationLocationProvider.enableSimulationOn(route)`).
2. Let it run to arrival (or call `stopNavigation()`).
3. Start navigation on the same route again.
4. Observe the first `TripState.Navigating`: `userLocation` is the previous trip's last fix, at the destination.
### What we see
On a 1 km route, ×100 simulation, logging our own trip narration:
```
trip 1 arrived: 969 m travelled, 74 fixes, 0 off-route episodes
trip 2 arrived: 0 m travelled, 4 fixes, 1 off-route episode <- ended instantly
```
Consequences we hit, all from that one stale location:
- A spurious `RouteDeviation` at the start of the new trip (the stale position is a kilometre from step 1's polyline), which for a route with a route provider also fires the deviation handler and asks for a reroute.
- `TripSummary.distanceTraveled` counts the jump home as walking: 1668 m for a 1000 m route.
- Anything that derives progress from position rather than from step advance treats the whole route as already behind the user. In our app that ended the new trip immediately, before a single real fix arrived (that part is our own logic; we now require two fixes before acting, but the stale location is upstream).
A route whose start and end are close together masks all of it, since the stale position is then already on step 1.
### Expected
A new session should not observe a location from a previous one. Either:
- `stopNavigation()` clears `_lastLocation` (and `startNavigation` falls back to `route.geometry.first()`), and/or
- `startNavigation` ignores a `_lastLocation` older than some threshold, and
- `SimulatedLocationProvider` does not replay a stale fix into a new session — `replay = 0`, or reset the replay cache in `setRoute` (e.g. build the shared flow per route, or use a `MutableSharedFlow` whose cache is cleared there).
### Environment
- Ferrostar 0.53.0, Android (Kotlin), `minSdk` 29
- `SimulatedLocationProvider` behind `NavigationLocationProvider`, reproduced at ×2 through ×100 warp
- Also reachable with the live provider, which is what #412 describes from the provider side
Contributor guide
Research direction
Start in android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt around startNavigation, stopNavigation, resumeNavigation, and replaceRoute, then inspect SimulatedLocationProvider.kt and its sharedUpdates flow. Reproduce the two-session scenario with NavigationLocationProvider.enableSimulationOn(route). Done means a new session's first state and simulated update do not reuse the previous session's final location.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, kotlin
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100