Skip to content

fix: resolve map 0,0 fallback race condition on cold start#611

Merged
torlando-tech merged 2 commits intomainfrom
fix/map-default-region-race-condition
Mar 7, 2026
Merged

fix: resolve map 0,0 fallback race condition on cold start#611
torlando-tech merged 2 commits intomainfrom
fix/map-default-region-race-condition

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

  • Fix race condition: Camera was set synchronously in onMapReady before the async default region lookup completed, causing fallback to 0,0 (Atlantic Ocean) on cold start
  • Implement missing fallback: getDefaultRegion() now falls back to the first completed offline region when no explicit default is set, as originally documented
  • Add defaultRegionLoaded state flag: Camera init is deferred to a LaunchedEffect that waits for the region lookup to complete before positioning the map

Closes #607

Test plan

  • Download an offline map region, force close and reopen — map should center on the offline region, never 0,0
  • Set a region as default favorite — map should center on that region
  • With no offline maps downloaded — should gracefully fall back to GPS or 0,0
  • Tab switching should still restore saved camera position correctly

🤖 Generated with Claude Code

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR fixes a cold-start race condition where the map camera fell back to 0,0 (Atlantic Ocean) because onMapReady applied the camera position synchronously before the async getDefaultRegion() DB query completed. The fix defers camera initialisation to a LaunchedEffect(mapLibreMap, state.defaultRegionLoaded) that waits for both the map instance and the DB lookup before positioning the camera, and simultaneously implements the previously-missing fallback that returns the first completed offline region when no explicit default is set.

Key changes:

  • defaultRegionLoaded: Boolean added to MapState as a synchronisation flag; set to true once refreshDefaultRegion() resolves (even when no region exists)
  • Camera-init logic moved from onMapReady into a new LaunchedEffect that gates on both mapLibreMap and defaultRegionLoaded, correctly handling all four priority levels: saved position → GPS → default region → 0,0 world view
  • savedPos branch placed before the hasInitiallyCentered guard, preserving tab-switch camera restoration (addresses the regression flagged in the previous review thread)
  • OfflineMapRegionRepository.getDefaultRegion() now falls back to getFirstCompletedRegion() (new ORDER BY createdAt ASC DAO query) instead of returning null
  • Minor open items: defaultRegionLoaded is never reset before a re-fetch (low risk today but fragile for future callers), and metersPerPixel is not refreshed when the camera-init effect exits early via hasInitiallyCentered

Confidence Score: 3/5

  • The core race-condition fix is sound, but the camera-init LaunchedEffect interacts with three other LaunchedEffect blocks in ways that can produce a brief redundant animation on simultaneous GPS + defaultRegionLoaded arrival.
  • The root-cause analysis is correct and the defaultRegionLoaded gating mechanism cleanly solves the original 0,0 race. The tab-switch regression from the previous review thread has been addressed by ordering savedPos before hasInitiallyCentered. The main remaining concerns are: (1) a potential redundant GPS camera animation when both the GPS and the camera-init LaunchedEffect fire for the same userLoc value, (2) metersPerPixel not being refreshed when the effect exits early, and (3) defaultRegionLoaded never being reset before re-fetches, which is harmless today but a latent fragility.
  • MapScreen.kt — the interactions between the four LaunchedEffect blocks (camera-init, GPS, focus-coordinates, default-region-animation) deserve careful manual testing across all scenarios in the test plan.

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt Camera init logic moved from synchronous onMapReady to a LaunchedEffect(mapLibreMap, state.defaultRegionLoaded) that correctly waits for both the map instance and the async DB lookup to complete. The savedPos branch is placed before the hasInitiallyCentered guard, fixing the previously-reported tab-switch regression. Minor concerns: potential redundant GPS re-animation in the userLoc branch, and metersPerPixel not being updated when the effect returns early.
app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt Adds defaultRegionLoaded: Boolean = false to MapState and sets it to true (alongside defaultRegionCenter) once refreshDefaultRegion() completes. This acts as the synchronisation signal for the deferred camera-init LaunchedEffect. Minor concern: the flag is never reset before a re-fetch, which works today but could silently break if refreshDefaultRegion() is ever called without a mapLibreMap change.
data/src/main/java/com/lxmf/messenger/data/db/dao/OfflineMapRegionDao.kt Adds getFirstCompletedRegion() query (STATUS = 'COMPLETE' ORDER BY createdAt ASC LIMIT 1). Clean addition; the createdAt ordering makes the fallback deterministic across multiple completed regions.
data/src/main/java/com/lxmf/messenger/data/repository/OfflineMapRegionRepository.kt Implements the previously-missing fallback in getDefaultRegion(): instead of returning null when no explicit default is set, it now delegates to getFirstCompletedRegion(). One-line change with clear intent and correct layer separation.

Sequence Diagram

sequenceDiagram
    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)
Loading

Last reviewed commit: c71309f

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Mar 6, 2026

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>
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

}
// For remaining sources, skip if already centered via another
// LaunchedEffect (focus coordinates or GPS).
hasInitiallyCentered -> return@LaunchedEffect
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +386 to +388
map.cameraPosition = initialPosition
metersPerPixel = map.projection.getMetersPerPixelAtLatitude(initialLat)
if (usedMeaningfulPosition) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@torlando-tech torlando-tech merged commit 332dd86 into main Mar 7, 2026
23 of 25 checks passed
@torlando-tech torlando-tech deleted the fix/map-default-region-race-condition branch March 7, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Timing issue while loading data, map falls back sometimes to 0,0

1 participant