diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 40b69877c..87dc370ee 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -36,11 +36,14 @@ jobs: - name: Run Android Lint run: ./gradlew lint - - name: Merge SARIF files - run: | - jq -s '{ "$schema": "https://json.schemastore.org/sarif-2.1.0", "version": "2.1.0", "runs": map(.runs) | add }' library/build/reports/lint-results-debug.sarif demo/build/reports/lint-results-debug.sarif > merged.sarif + - name: Upload SARIF for library + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: library/build/reports/lint-results-debug.sarif + category: library - - name: Upload SARIF file + - name: Upload SARIF for demo uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: merged.sarif + sarif_file: demo/build/reports/lint-results-debug.sarif + category: demo diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4eeaacbe..69974aa23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,20 +32,20 @@ jobs: java-version: '17' distribution: 'temurin' - uses: gradle/actions/wrapper-validation@v4 - - name: Create .gpg key + - name: Set up Gradle Publishing Environment Variables run: | echo $GPG_KEY_ARMOR | base64 --decode > ./release.asc gpg --quiet --output $GITHUB_WORKSPACE/release.gpg --dearmor ./release.asc echo "Build and publish" - sed -i -e "s,sonatypeToken=,sonatypeToken=$SONATYPE_TOKEN_USERNAME,g" gradle.properties + sed -i -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties SONATYPE_TOKEN_PASSWORD_ESCAPED=$(printf '%s\n' "$SONATYPE_TOKEN_PASSWORD" | sed -e 's/[\/&]/\\&/g') - sed -i -e "s,sonatypeTokenPassword=,sonatypeTokenPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties + sed -i -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties sed -i -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties sed -i -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties sed -i -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties env: - GPG_KEY_ARMOR: "${{ secrets.SYNCED_GPG_KEY_ARMOR }}" + GPG_KEY_ARMOR: ${{ secrets.SYNCED_GPG_KEY_ARMOR }} GPG_KEY_ID: ${{ secrets.SYNCED_GPG_KEY_ID }} GPG_PASSWORD: ${{ secrets.SYNCED_GPG_KEY_PASSWORD }} SONATYPE_TOKEN_PASSWORD: ${{ secrets.SONATYPE_TOKEN_PASSWORD }} @@ -56,7 +56,7 @@ jobs: node-version: '14' - name: Semantic Release - uses: cycjimmy/semantic-release-action@v4.2.0 + uses: cycjimmy/semantic-release-action@v4.2.2 with: extra_plugins: | "@semantic-release/commit-analyzer@8.0.1" diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index 61a65b959..930e60d97 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -49,7 +49,7 @@ jobs: - name: Jacoco Report to PR id: jacoco - uses: madrapps/jacoco-report@v1.7.1 + uses: madrapps/jacoco-report@v1.7.2 with: paths: | ${{ github.workspace }}/library/build/jacoco/jacoco.xml diff --git a/.releaserc b/.releaserc index 58210c177..5453d844b 100644 --- a/.releaserc +++ b/.releaserc @@ -20,7 +20,7 @@ plugins: to: ":${nextRelease.version}" - - "@semantic-release/exec" - prepareCmd: "./gradlew build --warn --stacktrace" - publishCmd: "./gradlew publish --warn --stacktrace --debug --info" + publishCmd: "./gradlew publishToMavenCentral --warn --stacktrace --debug --info" - - "@semantic-release/git" - assets: - "build.gradle.kts" diff --git a/README.md b/README.md index 5fa18c816..a3ea68db2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.13.0' + implementation 'com.google.maps.android:android-maps-utils:3.14.0' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx @@ -131,6 +131,8 @@ Contributions are welcome and encouraged! If you'd like to contribute, send us a This library uses Google Maps Platform services. Use of Google Maps Platform services through this library is subject to the Google Maps Platform [Terms of Service]. +If your billing address is in the European Economic Area, effective on 8 July 2025, the [Google Maps Platform EEA Terms of Service](https://cloud.google.com/terms/maps-platform/eea) will apply to your use of the Services. Functionality varies by region. [Learn more](https://developers.google.com/maps/comms/eea/faq). + This library is not a Google Maps Platform Core Service. Therefore, the Google Maps Platform Terms of Service (e.g. Technical Support Services, Service Level Agreements, and Deprecation Policy) do not apply to the code in this library. ## Support diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 0525b582a..e1fc0f723 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(libs.gradle) implementation(libs.dokka.gradle.plugin) implementation(libs.org.jacoco.core) + implementation(libs.gradle.maven.publish.plugin) } gradlePlugin { diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 2740add13..a476a1584 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,40 +15,34 @@ */ // buildSrc/src/main/kotlin/PublishingConventionPlugin.kt +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.MavenPublishBaseExtension import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication import org.gradle.kotlin.dsl.* import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.api.tasks.testing.Test import org.gradle.testing.jacoco.plugins.JacocoTaskExtension -import org.gradle.plugins.signing.SigningExtension -import org.gradle.api.publish.maven.* class PublishingConventionPlugin : Plugin { override fun apply(project: Project) { project.run { - applyPlugins() configureJacoco() - configurePublishing() - configureSigning() + configureVanniktechPublishing() } } private fun Project.applyPlugins() { apply(plugin = "com.android.library") apply(plugin = "com.mxalbert.gradle.jacoco-android") - apply(plugin = "maven-publish") apply(plugin = "org.jetbrains.dokka") - apply(plugin = "signing") + apply(plugin = "com.vanniktech.maven.publish") } private fun Project.configureJacoco() { configure { toolVersion = "0.8.7" - } tasks.withType().configureEach { @@ -59,76 +53,46 @@ class PublishingConventionPlugin : Plugin { } } - private fun Project.configurePublishing() { - extensions.configure { - publishing { - singleVariant("release") { - withSourcesJar() - withJavadocJar() - } - } - } - extensions.configure { - publications { - create("aar") { - artifactId = if (project.name == "library") { - "android-maps-utils" - } else { - null - } + private fun Project.configureVanniktechPublishing() { + extensions.configure { + configure( + AndroidSingleVariantLibrary( + variant = "release", + sourcesJar = true, + publishJavadocJar = true + ) + ) - afterEvaluate { - from(components["release"]) - } - pom { - name.set(project.name) - description.set("Handy extensions to the Google Maps Android API.") - url.set("https://github.com/googlemaps/android-maps-utils") - scm { - connection.set("scm:git@github.com:googlemaps/android-maps-utils.git") - developerConnection.set("scm:git@github.com:googlemaps/android-maps-utils.git") - url.set("scm:git@github.com:googlemaps/android-maps-utils.git") - } - licenses { - license { - name.set("The Apache Software License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - distribution.set("repo") - } - } - organization { - name.set("Google Inc") - url.set("http://developers.google.com/maps") - } - developers { - developer { - name.set("Google Inc.") - } - } + publishToMavenCentral() + signAllPublications() + + pom { + name.set(project.name) + description.set("Handy extensions to the Google Maps Android API.") + url.set("https://github.com/googlemaps/android-maps-utils") + licenses { + license { + name.set("The Apache Software License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") } } - } - repositories { - maven { - val releasesRepoUrl = - uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") - val snapshotsRepoUrl = - uri("https://oss.sonatype.org/content/repositories/snapshots/") - url = if (project.version.toString() - .endsWith("SNAPSHOT") - ) snapshotsRepoUrl else releasesRepoUrl - credentials { - username = project.findProperty("sonatypeToken") as String? - password = project.findProperty("sonatypeTokenPassword") as String? + scm { + connection.set("scm:git@github.com:googlemaps/android-maps-utils.git") + developerConnection.set("scm:git@github.com:googlemaps/android-maps-utils.git") + url.set("https://github.com/googlemaps/android-maps-utils") + } + developers { + developer { + id.set("google") + name.set("Google Inc.") } } + organization { + name.set("Google Inc") + url.set("http://developers.google.com/maps") + } } } } - - private fun Project.configureSigning() { - configure { - sign(extensions.getByType().publications["aar"]) - } - } } diff --git a/build.gradle.kts b/build.gradle.kts index ece75bdee..ab894b2dc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.13.0" + version = "3.14.0" } \ No newline at end of file diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 9556a50af..e2c64060b 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -26,10 +26,10 @@ android { } defaultConfig { - compileSdk = 35 + compileSdk = libs.versions.compileSdk.get().toInt() applicationId = "com.google.maps.android.utils.demo" minSdk = 21 - targetSdk = 35 + targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" } diff --git a/gradle.properties b/gradle.properties index 247dde2dd..c0f003139 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,7 +30,11 @@ signing.keyId= signing.password= signing.secretKeyRingFile= -sonatypeToken= -sonatypeTokenPassword= - android.defaults.buildfeatures.buildconfig=true + +mavenCentralUsername= +mavenCentralPassword= + +# Add a property to enable automatic release to Maven Central (optional, but good for CI) +# If true, publishToMavenCentral will also close and release the staging repository +mavenCentralAutomaticRelease=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb6e15d39..211c66ba1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,27 @@ [versions] -appcompat = "1.7.0" +compileSdk = "36" +targetSdk = "36" +appcompat = "1.7.1" dokka-gradle-plugin = "2.0.0" -gradle = "8.9.1" +gradle = "8.12.0" jacoco-android = "0.2.1" lifecycle-extensions = "2.2.0" -lifecycle-viewmodel-ktx = "2.9.0" -kotlin = "2.1.20" +lifecycle-viewmodel-ktx = "2.9.2" +kotlin = "2.2.0" kotlinx-coroutines = "1.10.2" junit = "4.13.2" -mockito-core = "5.17.0" +mockito-core = "5.18.0" secrets-gradle-plugin = "2.0.1" truth = "1.4.4" play-services-maps = "19.2.0" core-ktx = "1.16.0" -robolectric = "4.14.1" +robolectric = "4.15.1" kxml2 = "2.3.0" -mockk = "1.14.2" -lint = "31.10.0" +mockk = "1.14.5" +lint = "31.12.0" org-jacoco-core = "0.8.13" material = "1.12.0" +gradleMavenPublishPlugin = "0.34.0" [libraries] appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -47,4 +50,5 @@ lint = { module = "com.android.tools.lint:lint", version.ref = "lint" } lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lint" } testutils = { module = "com.android.tools:testutils", version.ref = "lint" } org-jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "org-jacoco-core" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } \ No newline at end of file +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradleMavenPublishPlugin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c820..37f853b1c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 38f34bd7f..9b0aa5b2e 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -24,9 +24,9 @@ android { sarifOutput = file("$buildDir/reports/lint-results.sarif") } defaultConfig { - compileSdk = 35 + compileSdk = libs.versions.compileSdk.get().toInt() minSdk = 21 - targetSdk = 35 + targetSdk = libs.versions.targetSdk.get().toInt() consumerProguardFiles("consumer-rules.pro") buildConfigField("String", "TRAVIS", "\"${System.getenv("TRAVIS")}\"") } diff --git a/library/src/main/java/com/google/maps/android/RendererLogger.java b/library/src/main/java/com/google/maps/android/RendererLogger.java new file mode 100644 index 000000000..2209ea45e --- /dev/null +++ b/library/src/main/java/com/google/maps/android/RendererLogger.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 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; + +import android.util.Log; + +/** + * Utility class for logging renderer-related debug output. + * + *

Use {@link #setEnabled(boolean)} to toggle logging globally. + * This class avoids the need for scattered conditionals in the codebase.

+ */ +public final class RendererLogger { + + private static boolean enabled = false; + + private RendererLogger() { + // Prevent instantiation + } + + /** + * Enables or disables logging. + * + * @param value {@code true} to enable logging; {@code false} to disable it. + */ + public static void setEnabled(boolean value) { + enabled = value; + } + + /** + * Logs a debug message if logging is enabled. + * + * @param tag Tag for the log message. + * @param message The debug message to log. + */ + public static void d(String tag, String message) { + if (enabled) { + Log.d(tag, message); + } + } + + /** + * Logs an info message if logging is enabled. + * + * @param tag Tag for the log message. + * @param message The info message to log. + */ + public static void i(String tag, String message) { + if (enabled) { + Log.i(tag, message); + } + } + + /** + * Logs a warning message if logging is enabled. + * + * @param tag Tag for the log message. + * @param message The warning message to log. + */ + public static void w(String tag, String message) { + if (enabled) { + Log.w(tag, message); + } + } + + /** + * Logs an error message if logging is enabled. + * + * @param tag Tag for the log message. + * @param message The error message to log. + */ + public static void e(String tag, String message) { + if (enabled) { + Log.e(tag, message); + } + } +} diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.java new file mode 100644 index 000000000..7e7ac329c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025 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.clustering.algo; + +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.clustering.ClusterItem; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * A variant of {@link NonHierarchicalDistanceBasedAlgorithm} that clusters items + * based on distance but assigns cluster positions at the centroid of their items, + * instead of using the position of a single item as the cluster position. + * + *

This algorithm overrides {@link #getClusters(float)} to compute a geographic centroid + * for each cluster and creates {@link StaticCluster} instances positioned at these centroids. + * This can provide a more accurate visual representation of the cluster location.

+ * + * @param the type of cluster item + */ +public class CentroidNonHierarchicalDistanceBasedAlgorithm + extends NonHierarchicalDistanceBasedAlgorithm { + + /** + * Computes the centroid (average latitude and longitude) of a collection of cluster items. + * + * @param items the collection of cluster items to compute the centroid for + * @return the centroid {@link LatLng} of the items + */ + protected LatLng computeCentroid(Collection items) { + double latSum = 0; + double lngSum = 0; + int count = 0; + for (T item : items) { + latSum += item.getPosition().latitude; + lngSum += item.getPosition().longitude; + count++; + } + return new LatLng(latSum / count, lngSum / count); + } + + /** + * Returns clusters of items for the given zoom level, with cluster positions + * set to the centroid of their constituent items rather than the position of + * any single item. + * + * @param zoom the current zoom level + * @return a set of clusters with centroid positions + */ + @Override + public Set> getClusters(float zoom) { + Set> originalClusters = super.getClusters(zoom); + Set> newClusters = new HashSet<>(); + + for (Cluster cluster : originalClusters) { + LatLng centroid = computeCentroid(cluster.getItems()); + StaticCluster newCluster = new StaticCluster<>(centroid); + for (T item : cluster.getItems()) { + newCluster.add(item); + } + newClusters.add(newCluster); + } + return newClusters; + } +} diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.java index 78daeff14..19f19880e 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.java +++ b/library/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.java @@ -44,7 +44,7 @@ public class GridBasedAlgorithm extends AbstractAlgorithm private int mGridSize = DEFAULT_GRID_SIZE; - private final Set mItems = Collections.synchronizedSet(new HashSet()); + private final Set mItems = Collections.synchronizedSet(new HashSet<>()); /** * Adds an item to the algorithm @@ -126,8 +126,8 @@ public Set> getClusters(float zoom) { long numCells = (long) Math.ceil(256 * Math.pow(2, zoom) / mGridSize); SphericalMercatorProjection proj = new SphericalMercatorProjection(numCells); - HashSet> clusters = new HashSet>(); - LongSparseArray> sparseArray = new LongSparseArray>(); + HashSet> clusters = new HashSet<>(); + LongSparseArray> sparseArray = new LongSparseArray<>(); synchronized (mItems) { for (T item : mItems) { @@ -137,7 +137,7 @@ public Set> getClusters(float zoom) { StaticCluster cluster = sparseArray.get(coord); if (cluster == null) { - cluster = new StaticCluster(proj.toLatLng(new Point(Math.floor(p.x) + .5, Math.floor(p.y) + .5))); + cluster = new StaticCluster<>(proj.toLatLng(new Point(Math.floor(p.x) + .5, Math.floor(p.y) + .5))); sparseArray.put(coord, cluster); clusters.add(cluster); } diff --git a/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java b/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java index f5462587c..9b1c5adf2 100644 --- a/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java +++ b/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java @@ -52,6 +52,7 @@ import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; import com.google.maps.android.R; +import com.google.maps.android.RendererLogger; import com.google.maps.android.clustering.Cluster; import com.google.maps.android.clustering.ClusterItem; import com.google.maps.android.clustering.ClusterManager; @@ -107,10 +108,7 @@ public enum AnimationType { public void setAnimationType(AnimationType type) { switch (type) { - case LINEAR: - animationInterp = new LinearInterpolator(); - break; - case EASE_IN: + case EASE_IN, ACCELERATE: animationInterp = new AccelerateInterpolator(); break; case EASE_OUT: @@ -125,9 +123,6 @@ public void setAnimationType(AnimationType type) { case BOUNCE: animationInterp = new BounceInterpolator(); break; - case ACCELERATE: - animationInterp = new AccelerateInterpolator(); - break; case DECELERATE: animationInterp = new DecelerateInterpolator(); break; @@ -196,35 +191,47 @@ public ClusterRendererMultipleItems(Context context, GoogleMap map, ClusterManag @Override public void onAdd() { - mClusterManager.getMarkerCollection().setOnMarkerClickListener(marker -> mItemClickListener != null && mItemClickListener.onClusterItemClick(mMarkerCache.get(marker))); + RendererLogger.d("ClusterRenderer", "Setting up MarkerCollection listeners"); + + mClusterManager.getMarkerCollection().setOnMarkerClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Marker clicked: " + marker); + return mItemClickListener != null && mItemClickListener.onClusterItemClick(mMarkerCache.get(marker)); + }); mClusterManager.getMarkerCollection().setOnInfoWindowClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Info window clicked for marker: " + marker); if (mItemInfoWindowClickListener != null) { mItemInfoWindowClickListener.onClusterItemInfoWindowClick(mMarkerCache.get(marker)); } }); mClusterManager.getMarkerCollection().setOnInfoWindowLongClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Info window long-clicked for marker: " + marker); if (mItemInfoWindowLongClickListener != null) { mItemInfoWindowLongClickListener.onClusterItemInfoWindowLongClick(mMarkerCache.get(marker)); } }); + RendererLogger.d("ClusterRenderer", "Setting up ClusterMarkerCollection listeners"); + mClusterManager.getClusterMarkerCollection().setOnMarkerClickListener(marker -> mClickListener != null && mClickListener.onClusterClick(mClusterMarkerCache.get(marker))); mClusterManager.getClusterMarkerCollection().setOnInfoWindowClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Info window clicked for cluster marker: " + marker); if (mInfoWindowClickListener != null) { mInfoWindowClickListener.onClusterInfoWindowClick(mClusterMarkerCache.get(marker)); } }); mClusterManager.getClusterMarkerCollection().setOnInfoWindowLongClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Info window long-clicked for cluster marker: " + marker); if (mInfoWindowLongClickListener != null) { mInfoWindowLongClickListener.onClusterInfoWindowLongClick(mClusterMarkerCache.get(marker)); } }); } + @Override public void onRemove() { mClusterManager.getMarkerCollection().setOnMarkerClickListener(null); @@ -270,6 +277,19 @@ public int getClusterTextAppearance(int clusterSize) { return R.style.amu_ClusterIcon_TextAppearance; // Default value } + /** + * Enables or disables logging for the cluster renderer. + * + *

When enabled, the renderer will log internal operations such as cluster rendering, + * marker updates, and other debug information. This is useful for development and debugging, + * but should typically be disabled in production builds.

+ * + * @param enabled {@code true} to enable logging; {@code false} to disable it. + */ + public void setLoggingEnabled(boolean enabled) { + RendererLogger.setEnabled(enabled); + } + @NonNull protected String getClusterText(int bucket) { if (bucket < BUCKETS[0]) { @@ -447,8 +467,9 @@ public void run() { try { visibleBounds = mProjection.getVisibleRegion().latLngBounds; + RendererLogger.d("ClusterRenderer", "Visible bounds calculated: " + visibleBounds); } catch (Exception e) { - e.printStackTrace(); + RendererLogger.e("ClusterRenderer", "Error getting visible bounds, defaulting to (0,0)"); visibleBounds = LatLngBounds.builder().include(new LatLng(0, 0)).build(); } @@ -462,6 +483,7 @@ public void run() { existingClustersOnScreen.add(point); } } + RendererLogger.d("ClusterRenderer", "Existing clusters on screen found: " + existingClustersOnScreen.size()); } // Create the new markers and animate them to their new positions. @@ -474,20 +496,25 @@ public void run() { if (closest != null) { LatLng animateFrom = mSphericalMercatorProjection.toLatLng(closest); markerModifier.add(true, new CreateMarkerTask(c, newMarkers, animateFrom)); + RendererLogger.d("ClusterRenderer", "Animating cluster from closest cluster: " + c.getPosition()); } else { markerModifier.add(true, new CreateMarkerTask(c, newMarkers, null)); + RendererLogger.d("ClusterRenderer", "Animating cluster without closest point: " + c.getPosition()); } } else { markerModifier.add(onScreen, new CreateMarkerTask(c, newMarkers, null)); + RendererLogger.d("ClusterRenderer", "Adding cluster without animation: " + c.getPosition()); } } // Wait for all markers to be added. markerModifier.waitUntilFree(); + RendererLogger.d("ClusterRenderer", "All new markers added, count: " + newMarkers.size()); // Don't remove any markers that were just added. This is basically anything that had a hit in the MarkerCache. markersToRemove.removeAll(newMarkers); + RendererLogger.d("ClusterRenderer", "Markers to remove after filtering new markers: " + markersToRemove.size()); // Find all of the new clusters that were added on-screen. These are candidates for markers to animate from. List newClustersOnScreen = null; @@ -499,6 +526,7 @@ public void run() { newClustersOnScreen.add(p); } } + RendererLogger.d("ClusterRenderer", "New clusters on screen found: " + newClustersOnScreen.size()); } for (final MarkerWithPosition marker : markersToRemove) { @@ -509,6 +537,7 @@ public void run() { if (closest != null) { LatLng animateTo = mSphericalMercatorProjection.toLatLng(closest); markerModifier.animateThenRemove(marker, marker.position, animateTo); + RendererLogger.d("ClusterRenderer", "Animating then removing marker at position: " + marker.position); } else if (mClusterMarkerCache.mCache.keySet().iterator().hasNext() && mClusterMarkerCache.mCache.keySet().iterator().next().getItems().contains(marker.clusterItem)) { T foundItem = null; for (Cluster cluster : mClusterMarkerCache.mCache.keySet()) { @@ -518,20 +547,23 @@ public void run() { break; } } - } // Remove it because it will join a cluster markerModifier.animateThenRemove(marker, marker.position, foundItem.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating then removing marker joining cluster at position: " + marker.position); } else { markerModifier.remove(true, marker.marker); + RendererLogger.d("ClusterRenderer", "Removing marker without animation at position: " + marker.position); } } else { markerModifier.remove(onScreen, marker.marker); + RendererLogger.d("ClusterRenderer", "Removing marker (onScreen=" + onScreen + ") at position: " + marker.position); } } // Wait until all marker removal operations are completed. markerModifier.waitUntilFree(); + RendererLogger.d("ClusterRenderer", "All marker removal operations completed."); mMarkers = newMarkers; ClusterRendererMultipleItems.this.mClusters = clusters; @@ -539,6 +571,7 @@ public void run() { // Run the callback once everything is done. mCallback.run(); + RendererLogger.d("ClusterRenderer", "Cluster update callback executed."); } } @@ -1076,13 +1109,16 @@ public CreateMarkerTask(Cluster c, Set markersAdded, LatL private void perform(MarkerModifier markerModifier) { // Don't show small clusters. Render the markers inside, instead. if (!shouldRenderAsCluster(cluster)) { + RendererLogger.d("ClusterRenderer", "Rendering individual cluster items, count: " + cluster.getItems().size()); for (T item : cluster.getItems()) { Marker marker = mMarkerCache.get(item); MarkerWithPosition markerWithPosition; LatLng currentLocation = item.getPosition(); if (marker == null) { + RendererLogger.d("ClusterRenderer", "Creating new marker for cluster item at position: " + currentLocation); MarkerOptions markerOptions = new MarkerOptions(); if (animateFrom != null) { + RendererLogger.d("ClusterRenderer", "Animating from position: " + animateFrom); markerOptions.position(animateFrom); } else if (mClusterMarkerCache.mCache.keySet().iterator().hasNext() && mClusterMarkerCache.mCache.keySet().iterator().next().getItems().contains(item)) { T foundItem = null; @@ -1095,6 +1131,7 @@ private void perform(MarkerModifier markerModifier) { } } currentLocation = foundItem.getPosition(); + RendererLogger.d("ClusterRenderer", "Found item in cache for animation at position: " + currentLocation); markerOptions.position(currentLocation); } else { markerOptions.position(item.getPosition()); @@ -1108,13 +1145,17 @@ private void perform(MarkerModifier markerModifier) { mMarkerCache.put(item, marker); if (animateFrom != null) { markerModifier.animate(markerWithPosition, animateFrom, item.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating marker from " + animateFrom + " to " + item.getPosition()); } else if (currentLocation != null) { markerModifier.animate(markerWithPosition, currentLocation, item.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating marker from " + currentLocation + " to " + item.getPosition()); } } else { markerWithPosition = new MarkerWithPosition<>(marker, item); markerModifier.animate(markerWithPosition, marker.getPosition(), item.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating existing marker from " + marker.getPosition() + " to " + item.getPosition()); if (!markerWithPosition.position.equals(item.getPosition())) { + RendererLogger.d("ClusterRenderer", "Updating cluster item marker position"); onClusterItemUpdated(item, marker); } } @@ -1125,9 +1166,11 @@ private void perform(MarkerModifier markerModifier) { } // Handle cluster markers + RendererLogger.d("ClusterRenderer", "Rendering cluster marker at position: " + cluster.getPosition()); Marker marker = mClusterMarkerCache.get(cluster); MarkerWithPosition markerWithPosition; if (marker == null) { + RendererLogger.d("ClusterRenderer", "Creating new cluster marker"); MarkerOptions markerOptions = new MarkerOptions().position(animateFrom == null ? cluster.getPosition() : animateFrom); onBeforeClusterRendered(cluster, markerOptions); marker = mClusterManager.getClusterMarkerCollection().addMarker(markerOptions); @@ -1135,9 +1178,11 @@ private void perform(MarkerModifier markerModifier) { markerWithPosition = new MarkerWithPosition(marker, null); if (animateFrom != null) { markerModifier.animate(markerWithPosition, animateFrom, cluster.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating cluster marker from " + animateFrom + " to " + cluster.getPosition()); } } else { markerWithPosition = new MarkerWithPosition(marker, null); + RendererLogger.d("ClusterRenderer", "Updating existing cluster marker"); onClusterUpdated(cluster, marker); } onClusterRendered(cluster, marker); diff --git a/library/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java b/library/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java new file mode 100644 index 000000000..ddcd89e6b --- /dev/null +++ b/library/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 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.clustering.algo; + +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.ClusterItem; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.Assert.assertEquals; + +import androidx.annotation.NonNull; + +public class CentroidNonHierarchicalDistanceBasedAlgorithmTest { + + static class TestClusterItem implements ClusterItem { + private final LatLng position; + + TestClusterItem(double lat, double lng) { + this.position = new LatLng(lat, lng); + } + + @NonNull + @Override + public LatLng getPosition() { + return position; + } + + @Override + public String getTitle() { + return null; + } + + @Override + public String getSnippet() { + return null; + } + + @Override + public Float getZIndex() { + return 0f; + } + } + + + + @Test + public void testComputeCentroid() { + CentroidNonHierarchicalDistanceBasedAlgorithm algo = + new CentroidNonHierarchicalDistanceBasedAlgorithm<>(); + + Collection items = Arrays.asList( + new TestClusterItem(10.0, 20.0), + new TestClusterItem(20.0, 30.0), + new TestClusterItem(30.0, 40.0) + ); + + LatLng centroid = algo.computeCentroid(items); + + assertEquals(20.0, centroid.latitude, 0.0001); + assertEquals(30.0, centroid.longitude, 0.0001); + } +}