fix(map): reuse cluster items across camera recompositions (#6605)

This commit is contained in:
simulationstation authored and GitHub committed 2026-08-11 15:17:17 +00:00
1 parent 7ab7d81345
commit 8899fdb2cf
2 files changed
+209 -15

No files matched your search

@@ -107,6 +107,9 @@ import com.google.maps.android.data.renderer.model.PolygonStyle
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@@ -146,11 +149,13 @@ import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.now
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Map
@@ -402,6 +407,13 @@ fun MapView(
}
val myNodeNum = mapViewModel.myNodeNum
val relativeTimeBucket = rememberRelativeTimeBucket()
val nodeClusterItems =
rememberNodeClusterItems(
nodes = if (mode is GoogleMapMode.Main) filteredNodes else emptyList(),
myNodeNum = myNodeNum,
relativeTimeBucket = relativeTimeBucket,
)
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
val theme by mapViewModel.theme.collectAsStateWithLifecycle()
val dark =
@@ -669,21 +681,7 @@ fun MapView(
when (mode) {
is GoogleMapMode.Main ->
MainMapContent(
nodeClusterItems =
filteredNodes.map { node ->
val latLng =
LatLng(
(node.position.latitude_i ?: 0) * DEG_D,
(node.position.longitude_i ?: 0) * DEG_D,
)
NodeClusterItem(
node = node,
nodePosition = latLng,
nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
nodeSnippet = "${node.user.long_name}",
myNodeNum = myNodeNum,
)
},
nodeClusterItems = nodeClusterItems,
mapFilterState = mapFilterState,
navigateToNodeDetails = navigateToNodeDetails,
displayableWaypoints = displayableWaypoints,
@@ -1025,6 +1023,53 @@ fun MapView(
}
}
private const val SECONDS_PER_MINUTE = 60L
private const val MILLIS_PER_SECOND = 1_000L
@Composable
private fun rememberRelativeTimeBucket(): Long {
val buckets = remember { relativeTimeBuckets() }
return buckets.collectAsStateWithLifecycle(initialValue = nowSeconds / SECONDS_PER_MINUTE).value
}
internal fun relativeTimeBuckets(now: () -> Long = { nowSeconds }): Flow<Long> = flow {
while (true) {
val currentSeconds = now()
emit(currentSeconds / SECONDS_PER_MINUTE)
val secondsUntilNextMinute = SECONDS_PER_MINUTE - currentSeconds.mod(SECONDS_PER_MINUTE)
delay(secondsUntilNextMinute * MILLIS_PER_SECOND)
}
}
/**
* Materializes the native clustering model used by the Google map.
*
* Camera state invalidates [MapView] on every movement frame, and its filters produce a new-but-equal [List] each time.
* Using that structural value as a key avoids rebuilding every [NodeClusterItem] (and its strings/[LatLng]) for
* camera-only changes. [relativeTimeBucket] deliberately refreshes the relative marker titles once per minute.
*/
@Composable
internal fun rememberNodeClusterItems(
nodes: List<Node>,
myNodeNum: Int?,
relativeTimeBucket: Long,
): List<NodeClusterItem> {
val unknownText = stringResource(Res.string.unknown)
val nowText = stringResource(Res.string.now)
return remember(nodes, myNodeNum, relativeTimeBucket, unknownText, nowText) {
nodes.map { node ->
val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
NodeClusterItem(
node = node,
nodePosition = latLng,
nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time, unknownText, nowText)}",
nodeSnippet = node.user.long_name,
myNodeNum = myNodeNum,
)
}
}
}
// region --- Main Map Content ---
@Suppress("LongParameterList")
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runComposeUiTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertTrue
@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MapNodeClusterItemsTest {
@Test
fun `camera-only recompositions reuse two thousand cluster items`() = runComposeUiTest {
val nodes = testNodes(count = NODE_COUNT)
var cameraFrame by mutableIntStateOf(0)
val observedLists = mutableListOf<List<NodeClusterItem>>()
setContent {
val filteredNodes = nodes.filter { cameraFrame >= 0 }
val items =
rememberNodeClusterItems(
nodes = filteredNodes,
myNodeNum = null,
relativeTimeBucket = FIXED_TIME_BUCKET,
)
SideEffect { observedLists += items }
}
waitForIdle()
repeat(SIMULATED_CAMERA_FRAMES) {
runOnIdle { cameraFrame += 1 }
waitForIdle()
}
assertEquals(SIMULATED_CAMERA_FRAMES + 1, observedLists.size)
val first = observedLists.first()
assertEquals(NODE_COUNT, first.size)
observedLists.drop(1).forEach { items ->
assertTrue(first === items, "camera-only changes must retain the same 2,000-item list")
}
}
@Test
fun `node changes and minute rollover refresh cluster items`() = runComposeUiTest {
var nodes by mutableStateOf(testNodes(count = 1))
var relativeTimeBucket by mutableLongStateOf(FIXED_TIME_BUCKET)
var latestItems: List<NodeClusterItem> = emptyList()
setContent {
val items =
rememberNodeClusterItems(nodes = nodes, myNodeNum = null, relativeTimeBucket = relativeTimeBucket)
SideEffect { latestItems = items }
}
waitForIdle()
val initialItems = latestItems
runOnIdle {
val node = nodes.single()
nodes = listOf(node.copy(user = node.user.copy(short_name = "NEW")))
}
waitForIdle()
val changedNodeItems = latestItems
assertNotSame(initialItems, changedNodeItems)
assertTrue(changedNodeItems.single().title.startsWith("NEW "))
runOnIdle { relativeTimeBucket += 1 }
waitForIdle()
assertNotSame(changedNodeItems, latestItems)
}
@Test
fun `relative time bucket advances at the next minute boundary`() = runTest {
val startSeconds = FIXED_TIME_BUCKET * 60 + 30
val observedBuckets = mutableListOf<Long>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
relativeTimeBuckets { startSeconds + testScheduler.currentTime / 1_000 }.take(2).toList(observedBuckets)
}
runCurrent()
assertEquals(listOf(FIXED_TIME_BUCKET), observedBuckets)
advanceTimeBy(29_999)
runCurrent()
assertEquals(listOf(FIXED_TIME_BUCKET), observedBuckets)
advanceTimeBy(1)
runCurrent()
assertEquals(listOf(FIXED_TIME_BUCKET, FIXED_TIME_BUCKET + 1), observedBuckets)
}
private fun testNodes(count: Int): List<Node> = List(count) { index ->
Node(
num = index + 1,
user = User(id = "!${index + 1}", long_name = "Node ${index + 1}", short_name = "N$index"),
position =
Position(
latitude_i = 210_000_000 + index,
longitude_i = -1_570_000_000 + index,
time = 1_700_000_000,
),
)
}
private companion object {
const val NODE_COUNT = 2_000
const val SIMULATED_CAMERA_FRAMES = 30
const val FIXED_TIME_BUCKET = 123L
}
}