Skip to content

Crash when using getClusterExpansionZoom, getClusterLeaves or getClusterChildren. Native exceptions not handled #3519

Description

@cgaisl

MapLibre Android Version

11.9.0

Android SDK Version

15

Device

Google Pixel 8a

What happened?

I have a map with some points. The points have a clustered GeoJsonSource that gets updated frequently when the user moves the map.

  1. On a map click, I query the rendered features with mapLibreMap.queryRenderedFeatures.
  2. I look for a cluster feature (feature.getBooleanProperty("cluster") == true)
  3. I want to zoom to the appropriate zoom level and call source.getClusterExpansionZoom(feature) to retrieve that zoom level

The problem I run into is the following: When the source is updated after I queried the rendered features, but before I call source.getClusterExpansionZoom or source.getClusterLeaves, I am calling these functions with a feature that no longer exists in the source.

For getClusterExpansionZoom I get the error: The associated promise has been destructed prior to the associated state becoming ready.

For getClusterLeaves I get the error: No cluster with the specified id.

The problem I have is this: even with a try/catch around these functions, the app still crashes. I believe there's some sort of uncaught exception in the native code?

I tried synchronizing calls to these functions with source.setGeoJson(), but since that function happens asynchronously in the native code, there's no way to tell when the new features will be available to query.

Here's a minimal Android App that reproduces the problem:


class MainActivity : Activity() {
    private lateinit var mapView: MapView
    private lateinit var mapLibreMap: MapLibreMap
    private lateinit var geoJsonSource: GeoJsonSource
    private val SOURCE_ID = "clustered-points"
    private val CLUSTER_LAYER_ID = "cluster-layer"
    private val SYMBOL_LAYER_ID = "symbol-layer"
    private val random = Random()
    private val updateInterval = 1.seconds

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        MapLibre.getInstance(this)
        setContentView(R.layout.activity_main)

        mapView = findViewById(R.id.mapView)
        mapView.onCreate(savedInstanceState)
        mapView.getMapAsync { map ->
            mapLibreMap = map
            setupMap()
            setupClickListener()
            simulateFrequentUpdates()
        }
    }

    private fun setupMap() {
        geoJsonSource = GeoJsonSource(
            SOURCE_ID,
            FeatureCollection.fromFeatures(generateRandomPoints(100)),
            GeoJsonOptions()
                .withCluster(true)
                .withClusterMaxZoom(14)
                .withClusterRadius(50)
        )

        mapLibreMap.setStyle("https://demotiles.maplibre.org/style.json") { style ->
            style.addSource(geoJsonSource)
            style.addLayer(
                CircleLayer(CLUSTER_LAYER_ID, SOURCE_ID)
                    .withProperties(
                        circleColor(Color.MAGENTA),
                        circleRadius(20f)
                    )
                    .withFilter(has("point_count"))
            )
            style.addLayer(
                SymbolLayer(SYMBOL_LAYER_ID, SOURCE_ID)
                    .withProperties(
                        textField("{point_count_abbreviated}"),
                        textSize(15f),
                        textColor(Color.WHITE),
                        textIgnorePlacement(true),
                        textAllowOverlap(true)
                    )
                    .withFilter(has("point_count"))
            )
        }
    }

    private fun setupClickListener() {
        mapLibreMap.addOnMapClickListener(
            MapLibreMap.OnMapClickListener { point ->
                val renderedFeatures = mapLibreMap.queryRenderedFeatures(
                    mapLibreMap.projection.toScreenLocation(point),
                    CLUSTER_LAYER_ID,
                )

                val clusterFeature = renderedFeatures
                    .firstOrNull { it.getBooleanProperty("cluster") }
                    ?: return@OnMapClickListener false

                GlobalScope.launch(Dispatchers.Main) {
                    delay(updateInterval) // add delay for reproducability

                    try {
                        val leaves = geoJsonSource.getClusterLeaves(clusterFeature, 10, 0).features() ?: emptyList()
                        println("Leaves.size: ${leaves.size}")
                        val zoom = geoJsonSource.getClusterExpansionZoom(clusterFeature)
                        println("Cluster expansion zoom: $zoom")
                    } catch (e: Exception) {
                        println("Exception")
                        e.printStackTrace()
                    }
                }

                true
            }
        )
    }

    private fun simulateFrequentUpdates() {
        fixedRateTimer(period = updateInterval.inWholeMilliseconds) {
            runOnUiThread {
                geoJsonSource.setGeoJson(FeatureCollection.fromFeatures(generateRandomPoints(500)))
                Log.d("MapLibreBug", "GeoJsonSource updated")
            }
        }
    }

    private fun generateRandomPoints(count: Int): List<Feature> {
        val features = mutableListOf<Feature>()
        for (i in 0 until count) {
            // Roughly Europe: latitude 36 to 71, longitude -10 to 40
            val lat = 36.0 + random.nextDouble() * (71.0 - 36.0)
            val lng = -10.0 + random.nextDouble() * (40.0 - (-10.0))
            features.add(Feature.fromGeometry(Point.fromLngLat(lng, lat)))
        }
        return features
    }

    override fun onStart() {
        super.onStart()
        mapView.onStart()
    }

    override fun onResume() {
        super.onResume()
        mapView.onResume()
    }

    override fun onPause() {
        super.onPause()
        mapView.onPause()
    }

    override fun onStop() {
        super.onStop()
        mapView.onStop()
    }

    override fun onLowMemory() {
        super.onLowMemory()
        mapView.onLowMemory()
    }

    override fun onDestroy() {
        super.onDestroy()
        mapView.onDestroy()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        mapView.onSaveInstanceState(outState)
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions