fix: resolve map 0,0 fallback race condition on cold start#611
fix: resolve map 0,0 fallback race condition on cold start#611torlando-tech merged 2 commits intomainfrom
Conversation
The map camera was set synchronously in onMapReady before the async default region lookup completed, causing it to fall through to the 0,0 (Atlantic Ocean) fallback. Fix by deferring camera init to a LaunchedEffect that waits for the region lookup to finish, and implement the documented fallback to the first completed region when no explicit default is set. Closes #607 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR fixes a cold-start race condition where the map camera fell back to 0,0 (Atlantic Ocean) because Key changes:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant VM as MapViewModel
participant DB as OfflineMapRegionDao
participant State as MapState
participant UI as MapScreen
participant Map as MapLibreMap
VM->>DB: refreshDefaultRegion()
Note over VM,DB: async coroutine
UI->>Map: MapView created (onMapReady)
Map-->>UI: mapLibreMap set (non-null)
UI->>UI: LaunchedEffect(mapLibreMap, defaultRegionLoaded)<br/>mapLibreMap non-null but defaultRegionLoaded=false → return early
DB-->>VM: getDefaultRegion() / getFirstCompletedRegion()
VM->>State: copy(defaultRegionCenter=..., defaultRegionLoaded=true)
State-->>UI: recompose (defaultRegionLoaded=true)
UI->>UI: LaunchedEffect re-fires (defaultRegionLoaded changed)
Note over UI: Priority: savedPos → GPS → defaultCenter → 0,0
UI->>Map: map.cameraPosition = resolved position
UI->>UI: hasInitiallyCentered = true (if meaningful position)
Last reviewed commit: c71309f |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
- Restructure hasInitiallyCentered gating so savedPos always restores (config change), 0,0 fallback doesn't lock out later GPS arrival - Add ORDER BY createdAt ASC to getFirstCompletedRegion() for deterministic region selection - Fix comment direction: "below" → "above" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| } | ||
| // For remaining sources, skip if already centered via another | ||
| // LaunchedEffect (focus coordinates or GPS). | ||
| hasInitiallyCentered -> return@LaunchedEffect |
There was a problem hiding this comment.
hasInitiallyCentered guard fires on cold start before GPS or offline fallback sets the flag
When defaultRegionLoaded fires but neither savedPos nor userLoc nor defaultCenter is available (pure no-data cold start), the else branch runs and correctly leaves hasInitiallyCentered = false. However, if GPS arrives first (before defaultRegionLoaded), the GPS LaunchedEffect at line 307 sets hasInitiallyCentered = true and also fires the camera. Then when defaultRegionLoaded becomes true, this LaunchedEffect re-runs and hits hasInitiallyCentered -> return@LaunchedEffect at line 360 — which is correct.
The issue is the reverse race: if defaultRegionLoaded fires while userLoc is non-null, the userLoc branch on line 361 sets the camera and hasInitiallyCentered = true using a synchronous assignment (map.cameraPosition = initialPosition). Then the GPS LaunchedEffect (line 307) fires separately with state.userLocation != null and calls animateCamera to the same position — a redundant no-op but potentially a visible re-animation. Since both effects share the same position source (state.userLocation), extracting it to a shared value would eliminate the duplicate positioning:
// Camera init LaunchedEffect, userLoc branch
userLoc != null -> {
initialLat = userLoc.latitude
initialLng = userLoc.longitude
initialZoom = 15.0
hasInitiallyCentered = true // set here so GPS LaunchedEffect skips animateCamera
}(This is effectively what the code already does via usedMeaningfulPosition, but setting hasInitiallyCentered eagerly before returning from the when block — rather than after line 388 — ensures the GPS LaunchedEffect cannot race in between the position assignment and the flag update.)
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt
Line: 360
Comment:
**`hasInitiallyCentered` guard fires on cold start before GPS or offline fallback sets the flag**
When `defaultRegionLoaded` fires but neither `savedPos` nor `userLoc` nor `defaultCenter` is available (pure no-data cold start), the `else` branch runs and correctly leaves `hasInitiallyCentered = false`. However, if GPS arrives first (before `defaultRegionLoaded`), the GPS `LaunchedEffect` at line 307 sets `hasInitiallyCentered = true` and also fires the camera. Then when `defaultRegionLoaded` becomes true, this `LaunchedEffect` re-runs and hits `hasInitiallyCentered -> return@LaunchedEffect` at line 360 — which is correct.
The issue is the **reverse race**: if `defaultRegionLoaded` fires while `userLoc` is non-null, the `userLoc` branch on line 361 sets the camera and `hasInitiallyCentered = true` using a *synchronous assignment* (`map.cameraPosition = initialPosition`). Then the GPS `LaunchedEffect` (line 307) fires separately with `state.userLocation != null` and calls `animateCamera` to the same position — a redundant no-op but potentially a visible re-animation. Since both effects share the same position source (`state.userLocation`), extracting it to a shared value would eliminate the duplicate positioning:
```kotlin
// Camera init LaunchedEffect, userLoc branch
userLoc != null -> {
initialLat = userLoc.latitude
initialLng = userLoc.longitude
initialZoom = 15.0
hasInitiallyCentered = true // set here so GPS LaunchedEffect skips animateCamera
}
```
(This is effectively what the code already does via `usedMeaningfulPosition`, but setting `hasInitiallyCentered` eagerly before returning from the `when` block — rather than after line 388 — ensures the GPS `LaunchedEffect` cannot race in between the position assignment and the flag update.)
How can I resolve this? If you propose a fix, please make it concise.| map.cameraPosition = initialPosition | ||
| metersPerPixel = map.projection.getMetersPerPixelAtLatitude(initialLat) | ||
| if (usedMeaningfulPosition) { |
There was a problem hiding this comment.
metersPerPixel not updated when effect returns early via hasInitiallyCentered
If hasInitiallyCentered was already set to true by the focus-coordinates or GPS LaunchedEffect before this block runs, the effect returns at line 360 and metersPerPixel is never updated for the initial viewport. This means the scale bar will display an incorrect value until the user moves the camera (which triggers the camera-move listener).
The camera-move listener should be the source of truth here anyway, but the mismatch window on first load (before any user interaction) could cause a confusing scale bar display. Consider computing metersPerPixel for whichever mapLibreMap instance just became available, regardless of whether the camera position was changed:
map.cameraPosition = initialPosition
metersPerPixel = map.projection.getMetersPerPixelAtLatitude(initialLat)
if (usedMeaningfulPosition) {
hasInitiallyCentered = true
}This is already the case when usedMeaningfulPosition is true, but a similar update for the early-return path (after the when block, before return@LaunchedEffect) would keep the scale bar accurate on first render.
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt
Line: 386-388
Comment:
**`metersPerPixel` not updated when effect returns early via `hasInitiallyCentered`**
If `hasInitiallyCentered` was already set to `true` by the focus-coordinates or GPS `LaunchedEffect` before this block runs, the effect returns at line 360 and `metersPerPixel` is never updated for the initial viewport. This means the scale bar will display an incorrect value until the user moves the camera (which triggers the camera-move listener).
The camera-move listener should be the source of truth here anyway, but the mismatch window on first load (before any user interaction) could cause a confusing scale bar display. Consider computing `metersPerPixel` for whichever `mapLibreMap` instance just became available, regardless of whether the camera position was changed:
```kotlin
map.cameraPosition = initialPosition
metersPerPixel = map.projection.getMetersPerPixelAtLatitude(initialLat)
if (usedMeaningfulPosition) {
hasInitiallyCentered = true
}
```
This is already the case when `usedMeaningfulPosition` is true, but a similar update for the early-return path (after the `when` block, before `return@LaunchedEffect`) would keep the scale bar accurate on first render.
How can I resolve this? If you propose a fix, please make it concise.| ) | ||
| } | ||
| _state.update { it.copy(defaultRegionCenter = newCenter) } | ||
| _state.update { it.copy(defaultRegionCenter = newCenter, defaultRegionLoaded = true) } |
There was a problem hiding this comment.
defaultRegionLoaded is never reset before a re-fetch
refreshDefaultRegion() is described as being called both on init and on return to the map screen. On re-entry with the ViewModel retained, defaultRegionLoaded is already true, so the _state.update is a no-op for that field — the camera-init LaunchedEffect will only re-run because mapLibreMap changes, not because of defaultRegionLoaded.
This is by design and works correctly for the current usage (re-entry always has savedPos != null). However, if refreshDefaultRegion() is called in a state where mapLibreMap has not changed (e.g., a future code path that updates the default without leaving the map screen), the camera-init LaunchedEffect won't re-fire. Resetting the flag to false at the start of refreshDefaultRegion() before the coroutine suspends would make the semantics more robust:
fun refreshDefaultRegion() {
_state.update { it.copy(defaultRegionLoaded = false) } // signal "loading"
viewModelScope.launch {
...
_state.update { it.copy(defaultRegionCenter = newCenter, defaultRegionLoaded = true) }
}
}Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt
Line: 464
Comment:
**`defaultRegionLoaded` is never reset before a re-fetch**
`refreshDefaultRegion()` is described as being called both on init and on return to the map screen. On re-entry with the ViewModel retained, `defaultRegionLoaded` is already `true`, so the `_state.update` is a no-op for that field — the camera-init `LaunchedEffect` will only re-run because `mapLibreMap` changes, not because of `defaultRegionLoaded`.
This is by design and works correctly for the current usage (re-entry always has `savedPos != null`). However, if `refreshDefaultRegion()` is called in a state where `mapLibreMap` has *not* changed (e.g., a future code path that updates the default without leaving the map screen), the camera-init `LaunchedEffect` won't re-fire. Resetting the flag to `false` at the start of `refreshDefaultRegion()` before the coroutine suspends would make the semantics more robust:
```kotlin
fun refreshDefaultRegion() {
_state.update { it.copy(defaultRegionLoaded = false) } // signal "loading"
viewModelScope.launch {
...
_state.update { it.copy(defaultRegionCenter = newCenter, defaultRegionLoaded = true) }
}
}
```
How can I resolve this? If you propose a fix, please make it concise.
Summary
onMapReadybefore the async default region lookup completed, causing fallback to 0,0 (Atlantic Ocean) on cold startgetDefaultRegion()now falls back to the first completed offline region when no explicit default is set, as originally documenteddefaultRegionLoadedstate flag: Camera init is deferred to aLaunchedEffectthat waits for the region lookup to complete before positioning the mapCloses #607
Test plan
🤖 Generated with Claude Code