Preserve complete globe map during partial loads

This commit is contained in:
callebtc 2026-08-01 01:45:25 +02:00
parent e2989269e0
commit 033e224bc7
3 changed files with 140 additions and 27 deletions

View File

@ -38,6 +38,7 @@ import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.globe.GlobeColors
import com.bitchat.android.ui.globe.GlobeMapPresentationState
import com.bitchat.android.ui.globe.GlobeMapUiState
import com.bitchat.android.ui.globe.GlobeState
import com.bitchat.android.ui.globe.GlobeTileRequest
@ -124,28 +125,29 @@ class GeohashPickerActivity : OrientationAwareActivity() {
DisposableEffect(mapRepository) {
onDispose { mapRepository.close() }
}
var mapUiState by remember { mutableStateOf(GlobeMapUiState()) }
var mapPresentationState by remember {
mutableStateOf(GlobeMapPresentationState())
}
var mapRetryNonce by remember { mutableIntStateOf(0) }
LaunchedEffect(globeState, mapRepository, mapRetryNonce) {
if (mapUiState.data.oceanPolygons.isEmpty()) {
mapUiState = mapUiState.copy(isLoading = true, hasError = false)
if (mapPresentationState.uiState.data.oceanPolygons.isEmpty()) {
mapPresentationState = mapPresentationState.startLoading()
try {
val overview = mapRepository.load(
GlobeTileRequest(detailZoom = 0, detailTiles = emptySet())
)
mapUiState = GlobeMapUiState(
data = overview.data,
isLoading = false,
hasError = false
mapPresentationState = mapPresentationState.copy(
uiState = GlobeMapUiState(
data = overview.data,
isLoading = false,
hasError = false
)
)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
mapUiState = mapUiState.copy(
isLoading = false,
hasError = true
)
mapPresentationState = mapPresentationState.showError()
return@LaunchedEffect
}
}
@ -171,36 +173,30 @@ class GeohashPickerActivity : OrientationAwareActivity() {
// in the flow (instead of filtering it) immediately cancels any
// obsolete HTTP/decode batch.
if (request == null) {
mapUiState = mapUiState.copy(isLoading = false)
mapPresentationState =
mapPresentationState.cancelLoading()
return@collectLatest
}
// Let fast drag/zoom changes settle before starting another batch.
delay(MAP_REQUEST_SETTLE_MS)
mapUiState = mapUiState.copy(isLoading = true, hasError = false)
mapPresentationState = mapPresentationState.startLoading()
try {
val result = mapRepository.load(request) { partial ->
mapUiState = GlobeMapUiState(
data = partial.data,
isLoading = true,
hasError = false
)
mapPresentationState =
mapPresentationState.showPartial(partial)
}
mapUiState = GlobeMapUiState(
data = result.data,
isLoading = false,
hasError = result.failedTileCount > 0
)
mapPresentationState =
mapPresentationState.showComplete(result)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
mapUiState = mapUiState.copy(
isLoading = false,
hasError = true
)
mapPresentationState = mapPresentationState.showError()
}
}
}
val mapUiState = mapPresentationState.uiState
val colorScheme = MaterialTheme.colorScheme
val dark = colorScheme.background.luminance() < 0.5f
val globeColors = remember(colorScheme, dark) {

View File

@ -195,3 +195,61 @@ data class GlobeMapUiState(
val isLoading: Boolean = false,
val hasError: Boolean = false
)
/**
* Keeps progressive tile updates from replacing a complete map with a priority-only
* subset. The first request may still render progressively; after one full successful
* detail request, cancellation and tile failures retain that complete snapshot.
*/
internal data class GlobeMapPresentationState(
val uiState: GlobeMapUiState = GlobeMapUiState(),
val lastCompleteData: GlobeMapData? = null
) {
fun startLoading(): GlobeMapPresentationState = copy(
uiState = uiState.copy(isLoading = true, hasError = false)
)
fun showPartial(result: GlobeMapLoadResult): GlobeMapPresentationState = copy(
uiState = GlobeMapUiState(
data = lastCompleteData ?: result.data,
isLoading = true,
hasError = false
)
)
fun showComplete(result: GlobeMapLoadResult): GlobeMapPresentationState {
val completedWithoutFailures = result.failedTileCount == 0
val displayedData = if (completedWithoutFailures) {
result.data
} else {
lastCompleteData ?: result.data
}
return copy(
uiState = GlobeMapUiState(
data = displayedData,
isLoading = false,
hasError = !completedWithoutFailures
),
lastCompleteData = if (completedWithoutFailures) {
result.data
} else {
lastCompleteData
}
)
}
fun cancelLoading(): GlobeMapPresentationState = copy(
uiState = uiState.copy(
data = lastCompleteData ?: uiState.data,
isLoading = false
)
)
fun showError(): GlobeMapPresentationState = copy(
uiState = uiState.copy(
data = lastCompleteData ?: uiState.data,
isLoading = false,
hasError = true
)
)
}

View File

@ -0,0 +1,59 @@
package com.bitchat.android.ui.globe
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeMapPresentationStateTest {
@Test
fun partialUpdateAfterCompleteLoadKeepsCompleteMapThroughCancellation() {
val completeData = GlobeMapData(detailZoom = 6)
val priorityOnlyData = GlobeMapData(detailZoom = 7)
var state = GlobeMapPresentationState().showComplete(
result(completeData)
)
state = state.startLoading().showPartial(result(priorityOnlyData))
assertSame(completeData, state.uiState.data)
assertTrue(state.uiState.isLoading)
state = state.cancelLoading()
assertSame(completeData, state.uiState.data)
assertFalse(state.uiState.isLoading)
}
@Test
fun failedReplacementAfterCompleteLoadKeepsCompleteMap() {
val completeData = GlobeMapData(detailZoom = 6)
val incompleteData = GlobeMapData(detailZoom = 7)
val state = GlobeMapPresentationState()
.showComplete(result(completeData))
.showComplete(result(incompleteData, failedTileCount = 1))
assertSame(completeData, state.uiState.data)
assertTrue(state.uiState.hasError)
assertSame(completeData, state.lastCompleteData)
}
@Test
fun firstLoadCanStillDisplayPriorityTilesProgressively() {
val priorityOnlyData = GlobeMapData(detailZoom = 4)
val state = GlobeMapPresentationState()
.showPartial(result(priorityOnlyData))
assertSame(priorityOnlyData, state.uiState.data)
assertTrue(state.uiState.isLoading)
}
private fun result(
data: GlobeMapData,
failedTileCount: Int = 0
): GlobeMapLoadResult = GlobeMapLoadResult(
data = data,
requestedTileCount = 5,
failedTileCount = failedTileCount
)
}