Skip to content

Commit 017b26b

Browse files
pengdevgithub-actions[bot]
authored andcommitted
[compose] Fix Composition leak in ViewAnnotation cleanup (#12389)
GitOrigin-RevId: a9235d4869f772c24c5d75d79d104a6d99f3ff59
1 parent e9d9f5d commit 017b26b

4 files changed

Lines changed: 185 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Mapbox welcomes participation and contributions from everyone.
1010
## Bug fixes 🐞
1111
* Fix attribution links accepting non-HTTP URI schemes.
1212
* Fix a potential file descriptor leak that could cause resource exhaustion.
13+
* [compose] Fixed Composition leak in ViewAnnotation when annotations are removed from the map.
1314

1415

1516
## Dependencies

extension-compose/build.gradle.kts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ android {
1515
targetSdk = libs.versions.androidTargetSdkVersion.get().toInt()
1616
unitTests.apply {
1717
isIncludeAndroidResources = true
18+
all {
19+
// JDK 17.0.7+ added StackTraceElement.HashedModules which Robolectric's
20+
// SandboxClassLoader tries to instrument. Without --add-opens the module
21+
// system blocks reflective access, crashing the test worker with
22+
// NoClassDefFoundError: Could not initialize class
23+
// java.lang.StackTraceElement$HashedModules
24+
it.jvmArgs(
25+
"--add-opens", "java.base/java.lang=ALL-UNNAMED",
26+
)
27+
}
1828
}
1929
}
2030

@@ -85,6 +95,7 @@ dependencies {
8595
testImplementation(libs.bundles.base.dependenciesTests)
8696
testImplementation(project(":maps-sdk"))
8797
testImplementation(libs.junit)
98+
testImplementation(libs.asyncInflater)
8899
}
89100
}
90101

extension-compose/src/main/java/com/mapbox/maps/extension/compose/annotation/ViewAnnotation.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ internal class ViewAnnotationNode(
140140
}
141141
removeViewAnnotation(view = view)
142142
}
143+
// Explicitly dispose the Composition to avoid leaking it until the Activity is destroyed.
144+
// The ViewCompositionStrategy is set to DisposeOnViewTreeLifecycleDestroyed to survive
145+
// temporary detach/reattach cycles in ViewAnnotationManagerImpl, so we need to manually
146+
// dispose when the annotation is actually removed.
147+
(view as? ComposeView)?.disposeComposition()
143148
updatedListener = null
144149
}
145150
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package com.mapbox.maps.extension.compose.annotation
2+
3+
import android.view.ViewPropertyAnimator
4+
import androidx.compose.ui.platform.ComposeView
5+
import androidx.test.core.app.ApplicationProvider
6+
import com.mapbox.maps.extension.compose.R
7+
import com.mapbox.maps.extension.compose.ShadowLogConfiguration
8+
import com.mapbox.maps.extension.compose.internal.MapNode
9+
import com.mapbox.maps.viewannotation.OnViewAnnotationUpdatedListener
10+
import com.mapbox.maps.viewannotation.ViewAnnotationManager
11+
import io.mockk.CapturingSlot
12+
import io.mockk.every
13+
import io.mockk.mockk
14+
import io.mockk.slot
15+
import io.mockk.spyk
16+
import io.mockk.unmockkAll
17+
import io.mockk.verify
18+
import org.junit.After
19+
import org.junit.Assert.assertNull
20+
import org.junit.Before
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import org.robolectric.RobolectricTestRunner
24+
import org.robolectric.annotation.Config
25+
26+
@Config(shadows = [ShadowLogConfiguration::class])
27+
@RunWith(RobolectricTestRunner::class)
28+
internal class ViewAnnotationNodeTest {
29+
30+
private lateinit var viewAnnotationManager: ViewAnnotationManager
31+
private lateinit var composeView: ComposeView
32+
private lateinit var parentNode: MapNode
33+
34+
@Before
35+
fun setup() {
36+
viewAnnotationManager = mockk(relaxed = true)
37+
composeView = spyk(ComposeView(ApplicationProvider.getApplicationContext()))
38+
parentNode = mockk(relaxed = true)
39+
}
40+
41+
@After
42+
fun tearDown() {
43+
unmockkAll()
44+
}
45+
46+
@Test
47+
fun `onRemoved disposes composition and removes view annotation`() {
48+
val node = ViewAnnotationNode(
49+
viewAnnotationManager = viewAnnotationManager,
50+
view = composeView,
51+
updatedListener = null,
52+
)
53+
54+
node.onRemoved(parentNode)
55+
56+
verify { composeView.disposeComposition() }
57+
verify { viewAnnotationManager.removeViewAnnotation(view = composeView) }
58+
}
59+
60+
@Test
61+
fun `onRemoved nulls updatedListener`() {
62+
val listener = mockk<OnViewAnnotationUpdatedListener>()
63+
val node = ViewAnnotationNode(
64+
viewAnnotationManager = viewAnnotationManager,
65+
view = composeView,
66+
updatedListener = listener,
67+
)
68+
69+
node.onAttached(parentNode)
70+
node.onRemoved(parentNode)
71+
72+
assertNull(node.updatedListener)
73+
}
74+
75+
@Test
76+
fun `onRemoved removes internal update listener when observing`() {
77+
val listener = mockk<OnViewAnnotationUpdatedListener>()
78+
val node = ViewAnnotationNode(
79+
viewAnnotationManager = viewAnnotationManager,
80+
view = composeView,
81+
updatedListener = listener,
82+
)
83+
84+
node.onAttached(parentNode)
85+
node.onRemoved(parentNode)
86+
87+
verify { viewAnnotationManager.removeOnViewAnnotationUpdatedListener(any()) }
88+
}
89+
90+
@Test
91+
fun `onRemoved skips removing internal update listener when not observing`() {
92+
val node = ViewAnnotationNode(
93+
viewAnnotationManager = viewAnnotationManager,
94+
view = composeView,
95+
updatedListener = null,
96+
)
97+
98+
node.onRemoved(parentNode)
99+
100+
verify(exactly = 0) { viewAnnotationManager.removeOnViewAnnotationUpdatedListener(any()) }
101+
}
102+
103+
@Test
104+
fun `onClear disposes composition and removes view annotation`() {
105+
val node = ViewAnnotationNode(
106+
viewAnnotationManager = viewAnnotationManager,
107+
view = composeView,
108+
updatedListener = null,
109+
)
110+
111+
node.onClear()
112+
113+
verify { composeView.disposeComposition() }
114+
verify { viewAnnotationManager.removeViewAnnotation(view = composeView) }
115+
}
116+
117+
@Test
118+
fun `onRemoved with disappear animation defers cleanup`() {
119+
setupDisappearAnimation()
120+
121+
val node = ViewAnnotationNode(
122+
viewAnnotationManager = viewAnnotationManager,
123+
view = composeView,
124+
updatedListener = null,
125+
)
126+
127+
node.onRemoved(parentNode)
128+
129+
verify(exactly = 0) { viewAnnotationManager.removeViewAnnotation(view = composeView) }
130+
verify(exactly = 0) { composeView.disposeComposition() }
131+
}
132+
133+
@Test
134+
fun `onRemoved with disappear animation cleans up after animation ends`() {
135+
val endActionSlot = slot<Runnable>()
136+
setupDisappearAnimation(endActionSlot)
137+
138+
val node = ViewAnnotationNode(
139+
viewAnnotationManager = viewAnnotationManager,
140+
view = composeView,
141+
updatedListener = null,
142+
)
143+
144+
node.onRemoved(parentNode)
145+
endActionSlot.captured.run()
146+
147+
verify { viewAnnotationManager.removeViewAnnotation(view = composeView) }
148+
verify { composeView.disposeComposition() }
149+
}
150+
151+
private fun setupDisappearAnimation(
152+
endActionSlot: CapturingSlot<Runnable>? = null,
153+
): ViewPropertyAnimator {
154+
val animationConfig = MarkerAnimationConfig(
155+
listOf(MarkerAnimationEffect.Effect.Fade(from = 1f, to = 0f))
156+
)
157+
composeView.setTag(R.id.markerDisappearAnimation, animationConfig)
158+
159+
val animator = mockk<ViewPropertyAnimator>(relaxed = true)
160+
every { composeView.animate() } returns animator
161+
every { animator.alpha(any()) } returns animator
162+
every { animator.setDuration(any()) } returns animator
163+
if (endActionSlot != null) {
164+
every { animator.withEndAction(capture(endActionSlot)) } returns animator
165+
}
166+
return animator
167+
}
168+
}

0 commit comments

Comments
 (0)