stadiamaps / stadiamaps/ferrostar

Android: a new session opens at the previous session's last location (restarted simulation arrives instantly)

Open
#949 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.