Skip to content

Commit 1d1a928

Browse files
committed
Fix memory leak in Compose masking for Session Replay
1 parent b03eb7f commit 1d1a928

File tree

5 files changed

+84
-5
lines changed

5 files changed

+84
-5
lines changed

buildSrc/src/main/java/Config.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ object Config {
194194
val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:4.1.0"
195195
val mockitoInline = "org.mockito:mockito-inline:4.8.0"
196196
val awaitility = "org.awaitility:awaitility-kotlin:4.1.1"
197+
val awaitility3 = "org.awaitility:awaitility-kotlin:3.1.6" // need this due to a conflict of awaitility4+ and espresso on hamcrest
197198
val mockWebserver = "com.squareup.okhttp3:mockwebserver:${Libs.okHttpVersion}"
198199
val jsonUnit = "net.javacrumbs.json-unit:json-unit:2.32.0"
199200
val hsqldb = "org.hsqldb:hsqldb:2.6.1"

sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ dependencies {
125125
androidTestImplementation(Config.TestLibs.mockWebserver)
126126
androidTestImplementation(Config.TestLibs.androidxJunit)
127127
androidTestImplementation(Config.TestLibs.leakCanaryInstrumentation)
128+
androidTestImplementation(Config.TestLibs.awaitility3)
128129
androidTestUtil(Config.TestLibs.androidxTestOrchestrator)
129130
}
130131

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package io.sentry.uitest.android
2+
3+
import android.app.Application
4+
import androidx.lifecycle.Lifecycle
5+
import androidx.test.core.app.launchActivity
6+
import io.sentry.SentryOptions
7+
import kotlinx.coroutines.delay
8+
import kotlinx.coroutines.runBlocking
9+
import leakcanary.AppWatcher
10+
import leakcanary.LeakAssertions
11+
import leakcanary.LeakCanary
12+
import org.awaitility.Duration
13+
import org.awaitility.kotlin.await
14+
import shark.AndroidReferenceMatchers
15+
import shark.IgnoredReferenceMatcher
16+
import shark.ReferencePattern
17+
import java.util.concurrent.atomic.AtomicBoolean
18+
import kotlin.test.Test
19+
20+
class ReplayTest : BaseUiTest() {
21+
@Test
22+
fun composeReplayDoesNotLeak() {
23+
val sent = AtomicBoolean(false)
24+
25+
LeakCanary.config = LeakCanary.config.copy(
26+
referenceMatchers = AndroidReferenceMatchers.appDefaults +
27+
listOf(
28+
IgnoredReferenceMatcher(
29+
ReferencePattern.InstanceFieldPattern(
30+
"com.saucelabs.rdcinjector.testfairy.TestFairyEventQueue",
31+
"context"
32+
)
33+
),
34+
// Seems like a false-positive returned by LeakCanary when curtains is used in
35+
// the host application (LeakCanary uses it itself internally). We use kind of
36+
// the same approach which possibly clashes with LeakCanary's internal state.
37+
// Only the case when replay is enabled.
38+
// TODO: check if it's actually a leak on our side, or a false-positive and report to LeakCanary's github issue tracker
39+
IgnoredReferenceMatcher(
40+
ReferencePattern.InstanceFieldPattern(
41+
"curtains.internal.RootViewsSpy",
42+
"delegatingViewList"
43+
)
44+
)
45+
) + ('a'..'z').map { char ->
46+
IgnoredReferenceMatcher(
47+
ReferencePattern.StaticFieldPattern(
48+
"com.testfairy.modules.capture.TouchListener",
49+
"$char"
50+
)
51+
)
52+
}
53+
)
54+
55+
val activityScenario = launchActivity<ComposeActivity>()
56+
activityScenario.moveToState(Lifecycle.State.RESUMED)
57+
58+
initSentry {
59+
it.experimental.sessionReplay.sessionSampleRate = 1.0
60+
61+
it.beforeSendReplay =
62+
SentryOptions.BeforeSendReplayCallback { event, _ ->
63+
sent.set(true)
64+
event
65+
}
66+
}
67+
68+
// wait until first segment is being sent
69+
await.untilTrue(sent)
70+
71+
activityScenario.moveToState(Lifecycle.State.DESTROYED)
72+
73+
LeakAssertions.assertNoLeaks()
74+
}
75+
}

sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.Color
88
import androidx.compose.ui.graphics.ColorProducer
99
import androidx.compose.ui.graphics.painter.Painter
1010
import androidx.compose.ui.layout.LayoutCoordinates
11+
import androidx.compose.ui.layout.findRootCoordinates
1112
import androidx.compose.ui.node.LayoutNode
1213
import androidx.compose.ui.text.TextLayoutResult
1314
import kotlin.math.roundToInt
@@ -165,8 +166,8 @@ private inline fun Float.fastCoerceAtMost(maximumValue: Float): Float {
165166
*
166167
* @return boundaries of this layout relative to the window's origin.
167168
*/
168-
internal fun LayoutCoordinates.boundsInWindow(root: LayoutCoordinates?): Rect {
169-
root ?: return Rect()
169+
internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates?): Rect {
170+
val root = rootCoordinates ?: findRootCoordinates()
170171

171172
val rootWidth = root.size.width.toFloat()
172173
val rootHeight = root.size.height.toFloat()

sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import io.sentry.android.replay.util.toOpaque
2727
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode
2828
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
2929
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
30+
import java.lang.ref.WeakReference
3031

3132
@TargetApi(26)
3233
internal object ComposeViewHierarchyNode {
@@ -62,7 +63,7 @@ internal object ComposeViewHierarchyNode {
6263
return options.experimental.sessionReplay.maskViewClasses.contains(className)
6364
}
6465

65-
private var _rootCoordinates: LayoutCoordinates? = null
66+
private var _rootCoordinates: WeakReference<LayoutCoordinates>? = null
6667

6768
private fun fromComposeNode(
6869
node: LayoutNode,
@@ -77,11 +78,11 @@ internal object ComposeViewHierarchyNode {
7778
}
7879

7980
if (isComposeRoot) {
80-
_rootCoordinates = node.coordinates.findRootCoordinates()
81+
_rootCoordinates = WeakReference(node.coordinates.findRootCoordinates())
8182
}
8283

8384
val semantics = node.collapsedSemantics
84-
val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates)
85+
val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates?.get())
8586
val isVisible = !node.outerCoordinator.isTransparent() &&
8687
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
8788
visibleRect.height() > 0 && visibleRect.width() > 0

0 commit comments

Comments
 (0)