Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ClusterManager<MyItem>>(1)
val items = listOf(MyItem(startingPosition, "Store Location 42", "Snippet", 0f))

initMapAndGetMarker(clusterManagerHolder) {
Clustering<MyItem>(
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<ClusterManager<MyItem>>(1)
// Item title is deliberately distinct from the custom contentDescription
val items = listOf(MyItem(startingPosition, "Store Location 42", "Snippet", 0f))

initMapAndGetMarker(clusterManagerHolder) {
Clustering<MyItem>(
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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ internal class ComposeUiClusterRenderer<T : ClusterItem>(
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)
}
}
}

Expand All @@ -324,7 +328,7 @@ internal class ComposeUiClusterRenderer<T : ClusterItem>(
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))

Expand All @@ -333,6 +337,13 @@ internal class ComposeUiClusterRenderer<T : ClusterItem>(
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)
}
}

Expand Down Expand Up @@ -394,7 +405,15 @@ internal class ComposeUiClusterRenderer<T : ClusterItem>(
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(
Expand All @@ -414,5 +433,21 @@ internal class ComposeUiClusterRenderer<T : ClusterItem>(
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 <T : ClusterItem> resolveItemContentDescription(
item: T,
customContentDescription: String?,
): String? = customContentDescription ?: item.title

/**
* Resolves the accessibility content description for a [Cluster].
*/
internal fun <T : ClusterItem> resolveClusterContentDescription(
cluster: Cluster<T>,
customContentDescription: String?,
): String? = customContentDescription
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a parameter to a public composable changes its JVM signature, so anything compiled against 9.0.x that calls ClusteringMarkerProperties(...) would fail with NoSuchMethodError until it is recompiled. Could we keep the old signature as a @Deprecated(level = DeprecationLevel.HIDDEN) overload that delegates to the new one? That keeps it binary compatible, as AGENTS.md asks for public API changes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Added the 3-parameter ClusteringMarkerProperties overload marked @Deprecated(level = DeprecationLevel.HIDDEN) delegating to the 4-parameter version, preserving binary compatibility for pre-compiled binaries.

) {
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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T : ClusterItem>(
override val items: Collection<T>,
override val position: LatLng,
) : Cluster<T> {
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)
}
}
Loading