diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt index c374b453a..017864bae 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.unit.dp import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.clustering.ClusterManager import com.google.maps.android.compose.clustering.Clustering import com.google.maps.android.compose.clustering.ClusteringMarkerProperties @@ -198,4 +200,112 @@ class GoogleMapViewClusteringTests { assertThat(marker.isVisible).isTrue() } } + + /** + * Inspects non-null String fields on [MarkerOptions] to extract contentDescription + * dynamically without hardcoding obfuscated Play services field names. + */ + private fun MarkerOptions.findCustomContentDescription(): String? { + for (field in MarkerOptions::class.java.declaredFields) { + if (field.type == String::class.java) { + field.isAccessible = true + val value = field.get(this) as? String + if (value != null && value != this.title && value != this.snippet) { + return value + } + } + } + return null + } + + private fun MarkerOptions.countStringFieldOccurrences(target: String): Int { + var count = 0 + for (field in MarkerOptions::class.java.declaredFields) { + if (field.type == String::class.java) { + field.isAccessible = true + if (field.get(this) == target) { + count++ + } + } + } + return count + } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testClusteringPropagatesItemTitleToMarkerContentDescription() { + val clusterManagerHolder = arrayOfNulls>(1) + val items = listOf(MyItem(startingPosition, "Store Location 42", "Snippet", 0f)) + + initMapAndGetMarker(clusterManagerHolder) { + Clustering( + items = items, + onClusterManager = { cm -> + clusterManagerHolder[0] = cm + } + ) + } + + composeTestRule.runOnUiThread { + val cm = clusterManagerHolder[0]!! + val renderer = cm.renderer + val method = renderer.javaClass.methods.firstOrNull { + it.name == "onBeforeClusterItemRendered" && it.parameterTypes.size == 2 + } ?: renderer.javaClass.getDeclaredMethod( + "onBeforeClusterItemRendered", + ClusterItem::class.java, + MarkerOptions::class.java + ).apply { isAccessible = true } + + val markerOptions = MarkerOptions() + method.invoke(renderer, items.first(), markerOptions) + + // When no custom description is supplied, fallback to item.title is populated on MarkerOptions. + // Both title and contentDescription fields hold "Store Location 42" (count == 2), + // whereas before the fix contentDescription remained null (count == 1). + assertThat(markerOptions.countStringFieldOccurrences("Store Location 42")).isEqualTo(2) + } + } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testClusteringMarkerPropertiesCustomContentDescription() { + val clusterManagerHolder = arrayOfNulls>(1) + // Item title is deliberately distinct from the custom contentDescription + val items = listOf(MyItem(startingPosition, "Store Location 42", "Snippet", 0f)) + + initMapAndGetMarker(clusterManagerHolder) { + Clustering( + items = items, + clusterItemContent = { + ClusteringMarkerProperties( + contentDescription = "Custom Accessibility Pin Description" + ) + Surface(modifier = Modifier.size(20.dp)) { + Text("X") + } + }, + onClusterManager = { cm -> + clusterManagerHolder[0] = cm + } + ) + } + + composeTestRule.runOnUiThread { + val cm = clusterManagerHolder[0]!! + val renderer = cm.renderer + val method = renderer.javaClass.methods.firstOrNull { + it.name == "onBeforeClusterItemRendered" && it.parameterTypes.size == 2 + } ?: renderer.javaClass.getDeclaredMethod( + "onBeforeClusterItemRendered", + ClusterItem::class.java, + MarkerOptions::class.java + ).apply { isAccessible = true } + + val markerOptions = MarkerOptions() + method.invoke(renderer, items.first(), markerOptions) + + assertThat(markerOptions.findCustomContentDescription()).isEqualTo("Custom Accessibility Pin Description") + } + } } diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt index cf37d5174..7a12547e1 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt @@ -300,6 +300,10 @@ internal class ComposeUiClusterRenderer( markerOptions.anchor(anchor.x, anchor.y) markerOptions.zIndex(props?.zIndex ?: clusterContentZIndexState.value) markerOptions.rotation(props?.rotation ?: clusterContentRotationState.value) + val contentDesc = resolveClusterContentDescription(cluster, props?.contentDescription) + if (!contentDesc.isNullOrEmpty()) { + markerOptions.contentDescription(contentDesc) + } } } @@ -324,7 +328,7 @@ internal class ComposeUiClusterRenderer( super.onBeforeClusterItemRendered(item, markerOptions) if (!scope.isActive) return - if (clusterItemContentState.value != null) { + val contentDesc = if (clusterItemContentState.value != null) { val viewInfo = keysToViews[ViewKey.Item(item)] ?: createAndAddView(ViewKey.Item(item)) markerOptions.icon(renderViewToBitmapDescriptor(viewInfo.view)) @@ -333,6 +337,13 @@ internal class ComposeUiClusterRenderer( markerOptions.anchor(anchor.x, anchor.y) markerOptions.zIndex(props.zIndex ?: clusterItemContentZIndexState.value) markerOptions.rotation(props.rotation ?: clusterItemContentRotationState.value) + resolveItemContentDescription(item, props.contentDescription) + } else { + resolveItemContentDescription(item, null) + } + + if (!contentDesc.isNullOrEmpty()) { + markerOptions.contentDescription(contentDesc) } } @@ -394,7 +405,15 @@ internal class ComposeUiClusterRenderer( val rotation = getRotationOverride() val anchor = getAnchor() val zIndex = getZIndex() - LaunchedEffect(properties.anchor, properties.zIndex, properties.rotation, rotation, anchor, zIndex) { + LaunchedEffect( + properties.anchor, + properties.zIndex, + properties.rotation, + properties.contentDescription, + rotation, + anchor, + zIndex + ) { invalidate() } CompositionLocalProvider( @@ -414,5 +433,21 @@ internal class ComposeUiClusterRenderer( onInvalidate?.invoke() } } - } + +/** + * Resolves the accessibility content description for an individual [ClusterItem]. + * If a custom [customContentDescription] is provided, it takes precedence; otherwise falls back to [ClusterItem.getTitle]. + */ +internal fun resolveItemContentDescription( + item: T, + customContentDescription: String?, +): String? = customContentDescription ?: item.title + +/** + * Resolves the accessibility content description for a [Cluster]. + */ +internal fun resolveClusterContentDescription( + cluster: Cluster, + customContentDescription: String?, +): String? = customContentDescription diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt index f65dbdf1a..6fa5682e8 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt @@ -60,6 +60,8 @@ public class ClusteringMarkerProperties { internal set public var rotation: Float? by mutableStateOf(null) internal set + public var contentDescription: String? by mutableStateOf(null) + internal set } /** @@ -78,21 +80,48 @@ public val LocalClusteringMarkerProperties: androidx.compose.runtime.ProvidableC * will be used. * @param rotation the rotation of the marker in degrees clockwise about the marker's anchor point. * If null, the default rotation specified in [Clustering] will be used. + * @param contentDescription the content description for accessibility purposes. If null, the title + * of the [ClusterItem] will be used as a fallback for cluster items. */ @Composable public fun ClusteringMarkerProperties( anchor: Offset? = null, zIndex: Float? = null, rotation: Float? = null, + contentDescription: String? = null, ) { val properties = LocalClusteringMarkerProperties.current SideEffect { properties.anchor = anchor properties.zIndex = zIndex properties.rotation = rotation + properties.contentDescription = contentDescription } } +/** + * Helper function to specify properties for the marker representing a cluster or cluster item. + * + * Retained for binary compatibility with binaries compiled against earlier versions. + */ +@Deprecated( + message = "Retained for binary compatibility.", + level = DeprecationLevel.HIDDEN, +) +@Composable +public fun ClusteringMarkerProperties( + anchor: Offset? = null, + zIndex: Float? = null, + rotation: Float? = null, +) { + ClusteringMarkerProperties( + anchor = anchor, + zIndex = zIndex, + rotation = rotation, + contentDescription = null, + ) +} + /** * Groups many items on a map based on zoom level. * diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterRendererContentDescriptionTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterRendererContentDescriptionTest.kt new file mode 100644 index 000000000..5a7ed094f --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterRendererContentDescriptionTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.clustering + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +public class ClusterRendererContentDescriptionTest { + + private class TestClusterItem( + override val position: LatLng, + override val title: String?, + override val snippet: String? = null, + override val zIndex: Float? = null, + ) : ClusterItem + + private class TestCluster( + override val items: Collection, + override val position: LatLng, + ) : Cluster { + override val size: Int get() = items.size + } + + @Test + public fun testResolveItemContentDescriptionFallsBackToItemTitleWhenNull() { + val item = TestClusterItem(LatLng(1.0, 2.0), title = "Store 42") + val result = resolveItemContentDescription(item, customContentDescription = null) + assertEquals("Store 42", result) + } + + @Test + public fun testResolveItemContentDescriptionCustomOverridesItemTitle() { + val item = TestClusterItem(LatLng(1.0, 2.0), title = "Store 42") + val result = resolveItemContentDescription( + item, + customContentDescription = "Custom Accessibility Pin" + ) + assertEquals("Custom Accessibility Pin", result) + } + + @Test + public fun testResolveItemContentDescriptionReturnsNullWhenBothNull() { + val item = TestClusterItem(LatLng(1.0, 2.0), title = null) + val result = resolveItemContentDescription(item, customContentDescription = null) + assertNull(result) + } + + @Test + public fun testResolveClusterContentDescription() { + val item = TestClusterItem(LatLng(1.0, 2.0), title = "Item") + val cluster = TestCluster(listOf(item), LatLng(1.0, 2.0)) + + val customResult = resolveClusterContentDescription( + cluster, + customContentDescription = "Cluster of 1 location" + ) + assertEquals("Cluster of 1 location", customResult) + + val defaultResult = resolveClusterContentDescription(cluster, customContentDescription = null) + assertNull(defaultResult) + } +}