Skip to content

Commit 1dc0dde

Browse files
kediarovgithub-actions[bot]
authored andcommitted
AnnotationManagerImpl cleanup images on delete (#11010)
https://mapbox.atlassian.net/browse/MAPSAND-2595 ## Changes ### `AnnotationManagerImpl` ref-counted style image tracking Added a `StyleImages` inner class that tracks a reference count per style image ID. Each annotation that holds an `_iconImage` starting with `ICON_DEFAULT_NAME_PREFIX` increments the counter on creation and decrements it on deletion. The style image is removed from the map only when the count reaches zero. The manager-level `iconImageBitmap` setter calls `addStyleImage()` → `styleImages.put(imageId)`, contributing its own ref-count entry. That entry is released by `deleteAll()` / manager teardown, but not by deleting individual annotations that don't carry an explicit `iconImage`. ### `PointAnnotation._iconImage` internal property + deprecation The existing public `iconImage` property now delegates to `_iconImage` and its getter is marked `@Deprecated` ## Tests for style image reference count | Test | Scenario | |------|----------| | `delete annotation with unique bitmap removes style image` | Single annotation deleted → image removed | | `deleteAll removes all unique style images` | Two unique bitmaps, deleteAll → both removed | | `deleting first of two shared-bitmap annotations preserves style image` | Same bitmap, delete one → image stays | | `deleting both shared-bitmap annotations removes style image` | Same bitmap, delete both → image removed | | `deleting source annotation preserves style image when cached-id annotation is alive` | A+B share id string, delete A → image stays; delete B → image removed | | `deleting individual annotation does not remove manager-level style image` | `manager.iconImageBitmap` set, delete annotation without explicit iconImage → image stays | | `deleteAll removes manager-level style image` | `manager.iconImageBitmap` set, deleteAll → image removed | | `creating annotation with previously deleted image re-adds style image` | Delete A (image removed), create B with same bitmap → image re-uploaded | | `creating string-id annotation after source annotation deleted does not re-add style image` | Delete A (image removed), create B with cached ID string only → image absent from style, B not shown | ## Caveats 1. If a user caches the image ID string from one annotation, deletes that annotation (which removes the image from the style), and then creates a new annotation with the cached ID via `withIconImage(String)`. The new annotation will not be rendered because no bitmap is available to re-upload the image. 2. Annotations sharing the same bitmap (same `hashCode`) share the same image ID and ref-count entry. Each creation increments and each deletion decrements the shared counter. Ticket https://mapbox.atlassian.net/browse/MAPSAND-2593 cc @mapbox/maps-android cc @mapbox/sdk-platform GitOrigin-RevId: 12b93934af2b15dba6849d02388e3be4b48af5b1
1 parent 07695d5 commit 1dc0dde

File tree

8 files changed

+537
-57
lines changed

8 files changed

+537
-57
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ Mapbox welcomes participation and contributions from everyone.
55
> **16 KB Page Size Support:** Starting with version 11.7.0 and 10.19.0, **NDK 27 is supported** with dedicated artifacts that include [support for 16 KB page sizes](https://developer.android.com/guide/practices/page-sizes). If your app does not require 16 KB page size support, you can keep using our default artifacts without `-ndk27` suffix. For more information about our NDK support, see https://docs.mapbox.com/android/maps/guides/#ndk-support
66
77
# main
8+
## Features ✨ and improvements 🏁
9+
* Deprecate `PointAnnotation.iconImage` getter. Reading this property exposes an internally generated image ID managed by the annotation manager. If you need a stable, reusable image ID, register the image in the style yourself via the Style API and pass the ID explicitly via `PointAnnotationOptions.withIconImage(String)`. In that case you are responsible for the image's lifecycle and must remove it from the style when no longer needed.
10+
811
## Bug fixes 🐞
12+
* Fix native memory leak in `AnnotationManager` where bitmap style images were not removed when annotations were deleted.
913
* Fix feature ID format mismatch in JNI marshaling where whole-number `double` feature IDs (e.g. `12345.0`) were incorrectly serialized as `"12345.000000"` instead of `"12345"`, causing `setFeatureState` to fail when using IDs obtained from `queryRenderedFeatures`.
1014

1115
# 11.21.0-rc.1 March 23, 2026

app/src/main/java/com/mapbox/maps/testapp/examples/markersandcallouts/viewannotation/ViewAnnotationWithPointAnnotationActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class ViewAnnotationWithPointAnnotationActivity : AppCompatActivity() {
6767
}
6868
// show / hide view annotation based on marker visibility
6969
binding.fabStyleToggle.setOnClickListener {
70-
if (pointAnnotation.iconImage == null) {
70+
if (pointAnnotation.iconImageBitmap == null) {
7171
pointAnnotation.iconImageBitmap = bitmapFromDrawableRes(R.drawable.ic_blue_marker)
7272
viewAnnotation.isVisible = true
7373
} else {

plugin-annotation/api/Release/metalava.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ package com.mapbox.maps.plugin.annotation.generated {
348348
method public String? getIconHaloColorString();
349349
method public String? getIconHaloColorUseTheme();
350350
method public Double? getIconHaloWidth();
351-
method public String? getIconImage();
351+
method @Deprecated public String? getIconImage();
352352
method public android.graphics.Bitmap? getIconImageBitmap();
353353
method @Deprecated public Double? getIconImageCrossFade();
354354
method public Double? getIconOcclusionOpacity();
@@ -441,7 +441,7 @@ package com.mapbox.maps.plugin.annotation.generated {
441441
property public final String? iconHaloColorString;
442442
property public final String? iconHaloColorUseTheme;
443443
property public final Double? iconHaloWidth;
444-
property public final String? iconImage;
444+
property @Deprecated public final String? iconImage;
445445
property public final android.graphics.Bitmap? iconImageBitmap;
446446
property @Deprecated public final Double? iconImageCrossFade;
447447
property public final Double? iconOcclusionOpacity;

plugin-annotation/src/main/java/com/mapbox/maps/plugin/annotation/AnnotationManagerImpl.kt

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ internal constructor(
8282
private var draggingAnnotation: T? = null
8383
private val annotationMap = LinkedHashMap<String, T>()
8484
private val dragAnnotationMap = LinkedHashMap<String, T>()
85+
private val styleImages = StyleImages()
8586
internal val dataDrivenPropertyDefaultValues: JsonObject = JsonObject()
8687

8788
private val interactionsCancelableSet = mutableSetOf<Cancelable>()
@@ -546,6 +547,7 @@ internal constructor(
546547
override fun create(option: S): T {
547548
return option.build(UUID.randomUUID().toString(), this).also {
548549
annotationMap[it.id] = it
550+
styleImages.put(it)
549551
updateSource()
550552
}
551553
}
@@ -557,6 +559,7 @@ internal constructor(
557559
val list = options.map { option ->
558560
option.build(UUID.randomUUID().toString(), this).also {
559561
annotationMap[it.id] = it
562+
styleImages.put(it)
560563
}
561564
}
562565
updateSource()
@@ -568,8 +571,10 @@ internal constructor(
568571
*/
569572
override fun delete(annotation: T) {
570573
if (annotationMap.remove(annotation.id) != null) {
574+
styleImages.remove(annotation)
571575
updateSource()
572576
} else if (dragAnnotationMap.remove(annotation.id) != null) {
577+
styleImages.remove(annotation)
573578
updateDragSource()
574579
} else {
575580
logW(
@@ -587,8 +592,10 @@ internal constructor(
587592
var needUpdateDragSource = false
588593
annotations.forEach {
589594
if (annotationMap.remove(it.id) != null) {
595+
styleImages.remove(it)
590596
needUpdateSource = true
591597
} else if (dragAnnotationMap.remove(it.id) != null) {
598+
styleImages.remove(it)
592599
needUpdateDragSource = true
593600
}
594601
}
@@ -612,6 +619,7 @@ internal constructor(
612619
dragAnnotationMap.clear()
613620
updateDragSource()
614621
}
622+
styleImages.clear()
615623
}
616624

617625
private fun updateDragSource() {
@@ -645,26 +653,15 @@ internal constructor(
645653
// Add a bitmap to the style.
646654
internal fun addStyleImage(imageId: String, bitmap: Bitmap) {
647655
delegateProvider.mapStyleManagerDelegate.addImage(image(imageId, bitmap))
648-
}
649-
650-
private fun removeIconsFromStyle(style: MapboxStyleManager, annotations: Collection<T>) {
651-
annotations.forEach { removeIconFromStyle(style, it) }
652-
}
653-
654-
private fun removeIconFromStyle(style: MapboxStyleManager, annotation: T) {
655-
val symbol = annotation as? PointAnnotation ?: return
656-
val imageId = symbol.iconImage ?: return
657-
if (!imageId.startsWith(PointAnnotation.ICON_DEFAULT_NAME_PREFIX)) return
658-
if (!style.hasStyleImage(imageId)) return
659-
style.removeStyleImage(imageId)
656+
styleImages.put(imageId)
660657
}
661658

662659
// Add icons to style from PointAnnotation.
663660
private fun addIconToStyle(style: MapboxStyleManager, annotations: Collection<T>) {
664661
// Add icon image bitmap from point annotation
665662
annotations.forEach { annotation ->
666663
(annotation as? PointAnnotation)?.let { symbol ->
667-
symbol.iconImage?.let { imageId ->
664+
symbol.iconImageInternal?.let { imageId ->
668665
if (imageId.startsWith(PointAnnotation.ICON_DEFAULT_NAME_PREFIX)) {
669666
/*
670667
* Basically if an image with the `imageId` already exists we don't add it again. The
@@ -774,7 +771,7 @@ internal constructor(
774771
style.removeStyleSource(it)
775772
}
776773
}
777-
removeIconsFromStyle(style, annotations)
774+
styleImages.clear()
778775

779776
unregisterInteractions()
780777
annotationMap.clear()
@@ -1001,6 +998,46 @@ internal constructor(
1001998
return properties.optBoolean("cluster", false)
1002999
}
10031000

1001+
private inner class StyleImages() {
1002+
1003+
private val images: MutableMap<String, Int> = mutableMapOf()
1004+
private val style get() = delegateProvider.mapStyleManagerDelegate
1005+
1006+
fun put(annotation: T) {
1007+
val imageId = (annotation as? PointAnnotation)?.iconImageInternal ?: return
1008+
if (!imageId.startsWith(PointAnnotation.ICON_DEFAULT_NAME_PREFIX)) return
1009+
put(imageId)
1010+
}
1011+
1012+
fun put(imageId: String) {
1013+
images[imageId] = (images[imageId] ?: 0) + 1
1014+
}
1015+
1016+
fun remove(annotation: T) {
1017+
val imageId = (annotation as? PointAnnotation)?.iconImageInternal ?: return
1018+
if (!imageId.startsWith(PointAnnotation.ICON_DEFAULT_NAME_PREFIX)) return
1019+
val newCount = (images[imageId] ?: return) - 1
1020+
if (newCount <= 0) {
1021+
images.remove(imageId)
1022+
if (style.hasStyleImage(imageId)) {
1023+
style.removeStyleImage(imageId)
1024+
}
1025+
} else {
1026+
images[imageId] = newCount
1027+
}
1028+
}
1029+
1030+
fun clear() {
1031+
val style = style
1032+
images.keys.forEach { imageId ->
1033+
if (style.hasStyleImage(imageId)) {
1034+
style.removeStyleImage(imageId)
1035+
}
1036+
}
1037+
images.clear()
1038+
}
1039+
}
1040+
10041041
/**
10051042
* Static variables and methods.
10061043
*/

plugin-annotation/src/main/java/com/mapbox/maps/plugin/annotation/generated/PointAnnotation.kt

Lines changed: 29 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugin-annotation/src/main/java/com/mapbox/maps/plugin/annotation/generated/PointAnnotationManager.kt

Lines changed: 35 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)