diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 6f1d9bf7ed..2669adac83 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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 = 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, + myNodeNum: Int?, + relativeTimeBucket: Long, +): List { + 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") diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt new file mode 100644 index 0000000000..fac8e6093e --- /dev/null +++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt @@ -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 . + */ +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>() + + 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 = 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() + 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 = 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 + } +}