From 033e224bc72d38823aecd1b23c31501771d9e54f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:45:25 +0200 Subject: [PATCH] Preserve complete globe map during partial loads --- .../android/ui/GeohashPickerActivity.kt | 50 ++++++++-------- .../bitchat/android/ui/globe/GlobeMapData.kt | 58 ++++++++++++++++++ .../ui/globe/GlobeMapPresentationStateTest.kt | 59 +++++++++++++++++++ 3 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMapPresentationStateTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index 89b560ce..470e363f 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeMapData.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeMapData.kt index 65a08ea8..653756ee 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeMapData.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeMapData.kt @@ -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 + ) + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMapPresentationStateTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMapPresentationStateTest.kt new file mode 100644 index 00000000..cb1f91ec --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMapPresentationStateTest.kt @@ -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 + ) +}