2121import io .sentry .protocol .SentryTransaction ;
2222import io .sentry .util .HintUtils ;
2323import io .sentry .util .Objects ;
24- import java .io .Closeable ;
25- import java .io .IOException ;
24+ import java .util .concurrent .CountDownLatch ;
25+ import java .util .concurrent .TimeUnit ;
26+ import java .util .concurrent .atomic .AtomicReference ;
2627import org .jetbrains .annotations .ApiStatus ;
2728import org .jetbrains .annotations .NotNull ;
2829import org .jetbrains .annotations .Nullable ;
3233 * captured.
3334 */
3435@ ApiStatus .Internal
35- public final class ScreenshotEventProcessor implements EventProcessor , Closeable {
36+ public final class ScreenshotEventProcessor implements EventProcessor {
3637
3738 private final @ NotNull SentryAndroidOptions options ;
3839 private final @ NotNull BuildInfoProvider buildInfoProvider ;
3940
4041 private final @ NotNull Debouncer debouncer ;
4142 private static final long DEBOUNCE_WAIT_TIME_MS = 2000 ;
4243 private static final int DEBOUNCE_MAX_EXECUTIONS = 3 ;
44+ private static final long MASKING_TIMEOUT_MS = 2000 ;
4345
44- private @ Nullable MaskRenderer maskRenderer = null ;
46+ private final boolean isReplayAvailable ;
4547
4648 public ScreenshotEventProcessor (
4749 final @ NotNull SentryAndroidOptions options ,
@@ -56,9 +58,7 @@ public ScreenshotEventProcessor(
5658 DEBOUNCE_WAIT_TIME_MS ,
5759 DEBOUNCE_MAX_EXECUTIONS );
5860
59- if (isReplayAvailable ) {
60- maskRenderer = new MaskRenderer ();
61- }
61+ this .isReplayAvailable = isReplayAvailable ;
6262
6363 if (options .isAttachScreenshot ()) {
6464 addIntegrationToSdkVersion ("Screenshot" );
@@ -69,7 +69,7 @@ private boolean isMaskingEnabled() {
6969 if (options .getScreenshotOptions ().getMaskViewClasses ().isEmpty ()) {
7070 return false ;
7171 }
72- if (maskRenderer == null ) {
72+ if (! isReplayAvailable ) {
7373 options
7474 .getLogger ()
7575 .log (SentryLevel .WARNING , "Screenshot masking requires sentry-android-replay module" );
@@ -124,14 +124,13 @@ private boolean isMaskingEnabled() {
124124
125125 // Apply masking if enabled and replay module is available
126126 if (isMaskingEnabled ()) {
127- final @ Nullable View rootView =
128- activity .getWindow () != null
129- && activity .getWindow ().peekDecorView () != null
130- && activity .getWindow ().peekDecorView ().getRootView () != null
131- ? activity .getWindow ().peekDecorView ().getRootView ()
132- : null ;
133- if (rootView != null ) {
134- screenshot = applyMasking (screenshot , rootView );
127+ final @ Nullable ViewHierarchyNode rootNode = captureViewHierarchy (activity );
128+ if (rootNode == null ) {
129+ return event ;
130+ }
131+ screenshot = applyMasking (screenshot , rootNode );
132+ if (screenshot == null ) {
133+ return event ;
135134 }
136135 }
137136
@@ -146,11 +145,65 @@ private boolean isMaskingEnabled() {
146145 return event ;
147146 }
148147
149- private @ NotNull Bitmap applyMasking (
150- final @ NotNull Bitmap screenshot , final @ NotNull View rootView ) {
148+ /**
149+ * Captures the view hierarchy on the main thread, since view traversal requires it. If already on
150+ * the main thread, captures directly; otherwise posts to the main thread and waits.
151+ */
152+ private @ Nullable ViewHierarchyNode captureViewHierarchy (final @ NotNull Activity activity ) {
153+ if (options .getThreadChecker ().isMainThread ()) {
154+ return buildViewHierarchy (activity );
155+ }
156+
157+ final AtomicReference <ViewHierarchyNode > result = new AtomicReference <>(null );
158+ final CountDownLatch latch = new CountDownLatch (1 );
159+
160+ activity .runOnUiThread (
161+ () -> {
162+ try {
163+ result .set (buildViewHierarchy (activity ));
164+ } finally {
165+ latch .countDown ();
166+ }
167+ });
168+
169+ try {
170+ if (!latch .await (MASKING_TIMEOUT_MS , TimeUnit .MILLISECONDS )) {
171+ options
172+ .getLogger ()
173+ .log (
174+ SentryLevel .WARNING , "Timed out waiting for view hierarchy capture on main thread" );
175+ return null ;
176+ }
177+ } catch (Throwable e ) {
178+ options .getLogger ().log (SentryLevel .ERROR , "Failed to capture view hierarchy" , e );
179+ return null ;
180+ }
181+
182+ return result .get ();
183+ }
184+
185+ private @ Nullable ViewHierarchyNode buildViewHierarchy (final @ NotNull Activity activity ) {
186+ final @ Nullable View rootView =
187+ activity .getWindow () != null
188+ && activity .getWindow ().peekDecorView () != null
189+ && activity .getWindow ().peekDecorView ().getRootView () != null
190+ ? activity .getWindow ().peekDecorView ().getRootView ()
191+ : null ;
192+ if (rootView == null ) {
193+ return null ;
194+ }
195+
196+ final ViewHierarchyNode rootNode =
197+ ViewHierarchyNode .Companion .fromView (rootView , null , 0 , options .getScreenshotOptions ());
198+ ViewsKt .traverse (rootView , rootNode , options .getScreenshotOptions (), options .getLogger ());
199+ return rootNode ;
200+ }
201+
202+ private @ Nullable Bitmap applyMasking (
203+ final @ NotNull Bitmap screenshot , final @ NotNull ViewHierarchyNode rootNode ) {
151204 Bitmap mutableBitmap = screenshot ;
152205 boolean createdCopy = false ;
153- try {
206+ try ( final MaskRenderer maskRenderer = new MaskRenderer ()) {
154207 // Make bitmap mutable if needed
155208 if (!screenshot .isMutable ()) {
156209 mutableBitmap = screenshot .copy (Bitmap .Config .ARGB_8888 , true );
@@ -160,16 +213,7 @@ private boolean isMaskingEnabled() {
160213 createdCopy = true ;
161214 }
162215
163- // we can access it here, since it's "internal" only for Kotlin
164-
165- // Build view hierarchy and apply masks
166- final ViewHierarchyNode rootNode =
167- ViewHierarchyNode .Companion .fromView (rootView , null , 0 , options .getScreenshotOptions ());
168- ViewsKt .traverse (rootView , rootNode , options .getScreenshotOptions (), options .getLogger ());
169-
170- if (maskRenderer != null ) {
171- maskRenderer .renderMasks (mutableBitmap , rootNode , null );
172- }
216+ maskRenderer .renderMasks (mutableBitmap , rootNode , null );
173217
174218 // Recycle original if we created a copy
175219 if (createdCopy && !screenshot .isRecycled ()) {
@@ -183,19 +227,13 @@ private boolean isMaskingEnabled() {
183227 if (createdCopy && !mutableBitmap .isRecycled ()) {
184228 mutableBitmap .recycle ();
185229 }
186- return screenshot ;
230+ // Don't return unmasked screenshot when masking is configured
231+ return null ;
187232 }
188233 }
189234
190235 @ Override
191236 public @ Nullable Long getOrder () {
192237 return 10000L ;
193238 }
194-
195- @ Override
196- public void close () throws IOException {
197- if (maskRenderer != null ) {
198- maskRenderer .close ();
199- }
200- }
201239}
0 commit comments