Skip to content

Commit ad8d4f6

Browse files
authored
Merge 13bfd36 into aed2cf7
2 parents aed2cf7 + 13bfd36 commit ad8d4f6

File tree

8 files changed

+97
-6
lines changed

8 files changed

+97
-6
lines changed

.github/workflows/agp-matrix.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
arch: x86
7171
channel: canary # Necessary for ATDs
7272
disk-size: 4096M
73-
script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -DtestBuildType=release --daemon
73+
script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -DtestBuildType=release -Denvironment=github --daemon
7474

7575
- name: Upload test results
7676
if: always()

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- Change TTFD timeout to 25 seconds ([#3984](https://github.com/getsentry/sentry-java/pull/3984))
8+
- Session Replay: Fix memory leak when masking Compose screens ([#3985](https://github.com/getsentry/sentry-java/pull/3985))
89

910
## 7.19.0
1011

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ android {
2626
// This doesn't work on some devices with Android 11+. Clearing package data resets permissions.
2727
// Check the readme for more info.
2828
testInstrumentationRunnerArguments["clearPackageData"] = "true"
29+
buildConfigField("String", "ENVIRONMENT", "\"${System.getProperty("environment", "")}\"")
2930
}
3031

3132
testOptions {
@@ -125,6 +126,7 @@ dependencies {
125126
androidTestImplementation(Config.TestLibs.mockWebserver)
126127
androidTestImplementation(Config.TestLibs.androidxJunit)
127128
androidTestImplementation(Config.TestLibs.leakCanaryInstrumentation)
129+
androidTestImplementation(Config.TestLibs.awaitility3)
128130
androidTestUtil(Config.TestLibs.androidxTestOrchestrator)
129131
}
130132

sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro

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

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)