From b33c38861e08b9733297963006cbc03538b23b64 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 13 Aug 2026 18:12:44 +0200 Subject: [PATCH 1/5] add cache for icon factory --- .../groundplatform/android/ui/IconFactory.kt | 31 ++++++++++++++----- .../gms/features/FeatureClusterRenderer.kt | 5 ++- .../ui/map/gms/features/FeatureManager.kt | 6 +++- .../features/FeatureClusterRendererTest.kt | 4 ++- .../ui/map/gms/features/FeatureManagerTest.kt | 2 ++ 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt b/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt index eb54117f2b..fcd9514daa 100644 --- a/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt +++ b/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt @@ -24,6 +24,7 @@ import android.graphics.PorterDuff import android.graphics.Rect import android.graphics.drawable.Drawable import android.os.Build +import android.util.LruCache import androidx.appcompat.content.res.AppCompatResources import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.BitmapDescriptorFactory @@ -36,6 +37,20 @@ import org.groundplatform.android.ui.util.obtainTextPaintFromStyle /** Responsible for building dynamically generated icon bitmaps. */ @Singleton class IconFactory @Inject constructor(@ApplicationContext private val context: Context) { + + private val markerIcons = + // One icon per job colour at each of two scales, so a handful per survey. + object : LruCache, BitmapDescriptor>(16) { + override fun create(key: Pair): BitmapDescriptor = + BitmapDescriptorFactory.fromBitmap(getMarkerBitmap(key.first, key.second)) + } + + private val clusterIcons = + // Clusters sit at least 100dp apart, so ~32 fit on a phone screen. + object : LruCache(32) { + override fun create(key: String): BitmapDescriptor = createClusterIcon(key) + } + /** Create a scaled bitmap based on the dimensions of a given [Drawable]. */ private fun createBitmap(drawable: Drawable, scale: Float = 1f): Bitmap { val width = (drawable.intrinsicWidth * scale).toInt() @@ -67,14 +82,16 @@ class IconFactory @Inject constructor(@ApplicationContext private val context: C return bitmap } - /** Returns a [BitmapDescriptor] for representing an individual marker on the map. */ - fun getMarkerIcon(color: Int, scale: Float): BitmapDescriptor { - val bitmap = getMarkerBitmap(color, scale) - return BitmapDescriptorFactory.fromBitmap(bitmap) - } + /** + * Returns a cached [BitmapDescriptor] representing an individual marker on the map. Descriptors + * are immutable, so one per [color] and [scale] is shared by every marker drawn with it. + */ + fun getMarkerIcon(color: Int, scale: Float): BitmapDescriptor = markerIcons.get(color to scale) + + /** Returns a cached [BitmapDescriptor] representing a marker cluster on the map. */ + fun getClusterIcon(text: String): BitmapDescriptor = clusterIcons.get(text) - /** Returns a [BitmapDescriptor] for representing a marker cluster on the map. */ - fun getClusterIcon(text: String): BitmapDescriptor { + private fun createClusterIcon(text: String): BitmapDescriptor { val fill = AppCompatResources.getDrawable(context, R.drawable.cluster_marker) val bitmap = createBitmap(fill!!) val canvas = Canvas(bitmap) diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRenderer.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRenderer.kt index 719cbc5284..5be0039a84 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRenderer.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRenderer.kt @@ -42,6 +42,7 @@ class FeatureClusterRenderer( map: GoogleMap, clusterManager: ClusterManager, private var zoom: Float, + private val iconFactory: IconFactory, ) : DefaultClusterRenderer(context, map, clusterManager) { /** * Called when the cluster balloon is shown so that implementations can unhide related map items. @@ -52,8 +53,6 @@ class FeatureClusterRenderer( */ lateinit var onClusterItemRendered: (Feature.Tag) -> Unit - private val markerIconFactory: IconFactory = IconFactory(context) - private var oldZoom = zoom fun setZoom(newZoom: Float) { @@ -77,7 +76,7 @@ class FeatureClusterRenderer( private fun createClusterIcon(cluster: Cluster): BitmapDescriptor { val itemsWithFlag = cluster.items.count { it.feature.flag } val totalItems = cluster.items.size - return markerIconFactory.getClusterIcon("$itemsWithFlag/$totalItems") + return iconFactory.getClusterIcon("$itemsWithFlag/$totalItems") } /** diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt index 52e8f4ec07..6035092130 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.groundplatform.android.di.coroutines.MainScope +import org.groundplatform.android.ui.IconFactory import org.groundplatform.android.ui.map.Feature import timber.log.Timber @@ -45,6 +46,8 @@ constructor( private val pointRenderer: PointRenderer, private val polygonRenderer: PolygonRenderer, private val lineStringRenderer: LineStringRenderer, + // Shared with PointRenderer so cluster icons hit the same cache instead of a per-renderer one. + private val iconFactory: IconFactory, ) { private val features = mutableSetOf() private val featuresByTag = mutableMapOf() @@ -80,7 +83,8 @@ constructor( ) } ) - clusterRenderer = FeatureClusterRenderer(context, map, clusterManager, map.cameraPosition.zoom) + clusterRenderer = + FeatureClusterRenderer(context, map, clusterManager, map.cameraPosition.zoom, iconFactory) clusterRenderer.onClusterItemRendered = { showClusterableItem(it) } clusterRenderer.onClusterRendered = { hideClusterableItem(it) } clusterManager.renderer = clusterRenderer diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRendererTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRendererTest.kt index 417d032314..ababc82897 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRendererTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRendererTest.kt @@ -23,6 +23,7 @@ import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterManager import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_CLUSTER_ITEM import org.groundplatform.android.common.Constants.CLUSTERING_ZOOM_THRESHOLD +import org.groundplatform.android.ui.IconFactory import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -51,7 +52,8 @@ class FeatureClusterRendererTest { fun setUp() { context = ApplicationProvider.getApplicationContext() com.google.android.gms.maps.MapsInitializer.initialize(context) - featureClusterRenderer = FeatureClusterRenderer(context, map, clusterManager, 10f) + featureClusterRenderer = + FeatureClusterRenderer(context, map, clusterManager, 10f, IconFactory(context)) } @Test diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt index be4f8a116d..e993bb5ba3 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt @@ -23,6 +23,7 @@ import com.google.android.gms.maps.model.Polygon as MapsPolygon import com.google.common.truth.Truth.assertThat import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm import kotlinx.coroutines.test.TestScope +import org.groundplatform.android.ui.IconFactory import org.groundplatform.android.ui.map.Feature import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.geometry.LinearRing @@ -67,6 +68,7 @@ class FeatureManagerTest { pointRenderer, polygonRenderer, lineStringRenderer, + IconFactory(ApplicationProvider.getApplicationContext()), ) featureManager.onMapReady(map) } From 05acfa8c358eb65baa9330a9fb7f6fef35ea1e2b Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 14 Aug 2026 10:37:20 +0200 Subject: [PATCH 2/5] dynamically calculate max number of cluster icons --- .../org/groundplatform/android/ui/IconFactory.kt | 10 +++++++--- .../ui/map/gms/features/FeatureManager.kt | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt b/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt index fcd9514daa..57b38793b7 100644 --- a/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt +++ b/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt @@ -39,18 +39,22 @@ import org.groundplatform.android.ui.util.obtainTextPaintFromStyle class IconFactory @Inject constructor(@ApplicationContext private val context: Context) { private val markerIcons = - // One icon per job colour at each of two scales, so a handful per survey. + // Bitmap cache keyed by job color and scale. Capacity of 16 covers most combinations and avoids + // recreating icons. object : LruCache, BitmapDescriptor>(16) { override fun create(key: Pair): BitmapDescriptor = BitmapDescriptorFactory.fromBitmap(getMarkerBitmap(key.first, key.second)) } private val clusterIcons = - // Clusters sit at least 100dp apart, so ~32 fit on a phone screen. - object : LruCache(32) { + object : LruCache(1) { override fun create(key: String): BitmapDescriptor = createClusterIcon(key) } + fun setClusterIconCacheSize(maxVisibleClusters: Int) { + clusterIcons.resize(maxVisibleClusters.coerceAtLeast(1)) + } + /** Create a scaled bitmap based on the dimensions of a given [Drawable]. */ private fun createBitmap(drawable: Drawable, scale: Float = 1f): Bitmap { val width = (drawable.intrinsicWidth * scale).toInt() diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt index 6035092130..973b31230f 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt @@ -75,14 +75,15 @@ constructor( mapsItemManager = MapsItemManager(map, pointRenderer, polygonRenderer, lineStringRenderer) clusterManager = FeatureClusterManager(context, map, createMarkerManager(map)) // Render only visible features; off-screen clusterable features are omitted - clusterManager.setAlgorithm( + val algorithm = with(context.resources.displayMetrics) { - NonHierarchicalViewBasedAlgorithm( + NonHierarchicalViewBasedAlgorithm( (widthPixels / density).toInt(), (heightPixels / density).toInt(), ) } - ) + clusterManager.setAlgorithm(algorithm) + iconFactory.setClusterIconCacheSize(maxVisibleClusters(algorithm)) clusterRenderer = FeatureClusterRenderer(context, map, clusterManager, map.cameraPosition.zoom, iconFactory) clusterRenderer.onClusterItemRendered = { showClusterableItem(it) } @@ -186,4 +187,13 @@ constructor( fun onCameraIdle() { clusterManager.onCameraIdle() } + + @VisibleForTesting + internal fun maxVisibleClusters(algorithm: NonHierarchicalViewBasedAlgorithm<*>): Int = + with(context.resources.displayMetrics) { + val spacingDp = algorithm.maxDistanceBetweenClusteredItems + val columns = widthPixels / density / spacingDp + val rows = heightPixels / density / spacingDp + (columns * rows).toInt() + } } From c8b9f0a4ce6e8e288db1bdf69d50af2209fb012c Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 14 Aug 2026 10:37:37 +0200 Subject: [PATCH 3/5] add unit tests --- .../android/ui/IconFactoryTest.kt | 53 +++++++++++++++++++ .../ui/map/gms/features/FeatureManagerTest.kt | 21 ++++++++ 2 files changed, 74 insertions(+) diff --git a/app/src/test/java/org/groundplatform/android/ui/IconFactoryTest.kt b/app/src/test/java/org/groundplatform/android/ui/IconFactoryTest.kt index d677e6581d..b692da34d3 100644 --- a/app/src/test/java/org/groundplatform/android/ui/IconFactoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/IconFactoryTest.kt @@ -19,14 +19,21 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Color import androidx.appcompat.content.res.AppCompatResources.getDrawable +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.BitmapDescriptorFactory import com.google.common.truth.Truth.assertThat import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.testing.HiltAndroidTest import javax.inject.Inject import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.R +import org.junit.After import org.junit.Test import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner @HiltAndroidTest @@ -39,6 +46,17 @@ class IconFactoryTest : BaseHiltTest() { private val unscaledWidth by lazy { testMarker!!.intrinsicWidth } private val unscaledHeight by lazy { testMarker!!.intrinsicHeight } + private val bitmapDescriptorFactory: MockedStatic = + mockStatic(BitmapDescriptorFactory::class.java).apply { + `when` { BitmapDescriptorFactory.fromBitmap(any()) } + .thenAnswer { mock() } + } + + @After + fun closeStaticMock() { + bitmapDescriptorFactory.close() + } + @Test fun `getMarkerBitmap() stretches marker`() { val bitmap = iconFactory.getMarkerBitmap(Color.BLUE, 2.0f) @@ -53,6 +71,41 @@ class IconFactoryTest : BaseHiltTest() { assertBitmapScale(bitmap, 0.5f) } + @Test + fun `getMarkerIcon returns the same instance for the same color and scale`() { + val first = iconFactory.getMarkerIcon(Color.BLUE, 2.0f) + + assertThat(iconFactory.getMarkerIcon(Color.BLUE, 2.0f)).isSameInstanceAs(first) + } + + @Test + fun `getMarkerIcon builds a distinct icon per color and per scale`() { + val blue = iconFactory.getMarkerIcon(Color.BLUE, 2.0f) + val red = iconFactory.getMarkerIcon(Color.RED, 2.0f) + val blueSelected = iconFactory.getMarkerIcon(Color.BLUE, 3.0f) + + assertThat(red).isNotSameInstanceAs(blue) + assertThat(blueSelected).isNotSameInstanceAs(blue) + } + + @Test + fun `getClusterIcon returns the same instance for the same label`() { + iconFactory.setClusterIconCacheSize(2) + val first = iconFactory.getClusterIcon("3/10") + + assertThat(iconFactory.getClusterIcon("4/10")).isNotSameInstanceAs(first) + assertThat(iconFactory.getClusterIcon("3/10")).isSameInstanceAs(first) + } + + @Test + fun `getClusterIcon evicts the least recently used label once the cache is full`() { + iconFactory.setClusterIconCacheSize(1) + val first = iconFactory.getClusterIcon("3/10") + iconFactory.getClusterIcon("4/10") + + assertThat(iconFactory.getClusterIcon("3/10")).isNotSameInstanceAs(first) + } + private fun assertBitmapScale(bitmap: Bitmap, scale: Float) { val expectedWidth = (unscaledWidth * scale).toInt() val expectedHeight = (unscaledHeight * scale).toInt() diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt index e993bb5ba3..7594c34105 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt @@ -39,6 +39,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) class FeatureManagerTest { @@ -146,6 +147,26 @@ class FeatureManagerTest { verify(mapsPolygon).remove() } + @Test + @Config(qualifiers = "w360dp-h800dp-xxhdpi") + fun `counts the clusters that fit on a phone screen`() { + // 360dp / 100dp = 3.6 columns, 800dp / 100dp = 8 rows, 3.6 * 8 = 28.8 cells. + assertThat(featureManager.maxVisibleClusters(clusterAlgorithm)).isEqualTo(28) + } + + @Test + @Config(qualifiers = "w360dp-h800dp-mdpi") + fun `counts the clusters that fit on screen independently of density`() { + assertThat(featureManager.maxVisibleClusters(clusterAlgorithm)).isEqualTo(28) + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun `counts the clusters that fit on a tablet screen`() { + // 1280dp / 100dp = 12.8 columns, 800dp / 100dp = 8 rows, 12.8 * 8 = 102.4 cells. + assertThat(featureManager.maxVisibleClusters(clusterAlgorithm)).isEqualTo(102) + } + private fun clusterableFeature(id: String) = Feature( tag = Feature.Tag(id, Feature.Type.LOCATION_OF_INTEREST), From 3a345a2202913aa92f23b170034efb0376dac258 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 14 Aug 2026 11:13:48 +0200 Subject: [PATCH 4/5] update code comments --- .../main/java/org/groundplatform/android/ui/IconFactory.kt | 5 +---- .../android/ui/map/gms/features/FeatureManager.kt | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt b/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt index 57b38793b7..d02abc3fd0 100644 --- a/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt +++ b/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt @@ -86,10 +86,7 @@ class IconFactory @Inject constructor(@ApplicationContext private val context: C return bitmap } - /** - * Returns a cached [BitmapDescriptor] representing an individual marker on the map. Descriptors - * are immutable, so one per [color] and [scale] is shared by every marker drawn with it. - */ + /** Returns a cached [BitmapDescriptor] representing an individual marker on the map. */ fun getMarkerIcon(color: Int, scale: Float): BitmapDescriptor = markerIcons.get(color to scale) /** Returns a cached [BitmapDescriptor] representing a marker cluster on the map. */ diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt index 973b31230f..ce6f9967b7 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt @@ -46,7 +46,6 @@ constructor( private val pointRenderer: PointRenderer, private val polygonRenderer: PolygonRenderer, private val lineStringRenderer: LineStringRenderer, - // Shared with PointRenderer so cluster icons hit the same cache instead of a per-renderer one. private val iconFactory: IconFactory, ) { private val features = mutableSetOf() @@ -189,7 +188,7 @@ constructor( } @VisibleForTesting - internal fun maxVisibleClusters(algorithm: NonHierarchicalViewBasedAlgorithm<*>): Int = + fun maxVisibleClusters(algorithm: NonHierarchicalViewBasedAlgorithm<*>): Int = with(context.resources.displayMetrics) { val spacingDp = algorithm.maxDistanceBetweenClusteredItems val columns = widthPixels / density / spacingDp From bae7a3aac4006ea8337bfde9a94f750b18022b87 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 17 Aug 2026 12:34:58 +0200 Subject: [PATCH 5/5] apply code review for default cache size as 16 for both markers and cluster icons --- .../org/groundplatform/android/ui/IconFactory.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt b/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt index d02abc3fd0..bdaa95fda2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt +++ b/app/src/main/java/org/groundplatform/android/ui/IconFactory.kt @@ -39,15 +39,15 @@ import org.groundplatform.android.ui.util.obtainTextPaintFromStyle class IconFactory @Inject constructor(@ApplicationContext private val context: Context) { private val markerIcons = - // Bitmap cache keyed by job color and scale. Capacity of 16 covers most combinations and avoids - // recreating icons. - object : LruCache, BitmapDescriptor>(16) { + // Bitmap cache keyed by job color and scale. + object : LruCache, BitmapDescriptor>(DEFAULT_ICON_CACHE_SIZE) { override fun create(key: Pair): BitmapDescriptor = BitmapDescriptorFactory.fromBitmap(getMarkerBitmap(key.first, key.second)) } + // Bitmap cache keyed by cluster label. Resized to fit the visible clusters once the map is ready. private val clusterIcons = - object : LruCache(1) { + object : LruCache(DEFAULT_ICON_CACHE_SIZE) { override fun create(key: String): BitmapDescriptor = createClusterIcon(key) } @@ -113,4 +113,9 @@ class IconFactory @Inject constructor(@ApplicationContext private val context: C return BitmapDescriptorFactory.fromBitmap(bitmap) } + + companion object { + /** Default capacity of the icon caches, sufficient to cover most combinations. */ + private const val DEFAULT_ICON_CACHE_SIZE = 16 + } }