mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-14 22:30:35 -04:00
fix(map): reuse cluster items across camera recompositions (#6605)
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user