Skip to content

Commit 0404ea3

Browse files
markushigetsentry-botromtsn
authored
Use PixelCopy API for capturing screenshots on API level 24+ (#3008)
Co-authored-by: Sentry Github Bot <[email protected]> Co-authored-by: Roman Zavarnitsyn <[email protected]>
1 parent c3f503e commit 0404ea3

File tree

4 files changed

+198
-18
lines changed

4 files changed

+198
-18
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

77
- Add thread information to spans ([#2998](https://github.com/getsentry/sentry-java/pull/2998))
8+
- Use PixelCopy API for capturing screenshots on API level 24+ ([#3008](https://github.com/getsentry/sentry-java/pull/3008))
89

910
### Fixes
1011

sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java

+76-18
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
import android.graphics.Bitmap;
66
import android.graphics.Canvas;
77
import android.os.Build;
8+
import android.os.Handler;
9+
import android.os.HandlerThread;
10+
import android.view.PixelCopy;
811
import android.view.View;
12+
import android.view.Window;
913
import androidx.annotation.Nullable;
1014
import io.sentry.ILogger;
1115
import io.sentry.SentryLevel;
@@ -14,6 +18,7 @@
1418
import java.io.ByteArrayOutputStream;
1519
import java.util.concurrent.CountDownLatch;
1620
import java.util.concurrent.TimeUnit;
21+
import java.util.concurrent.atomic.AtomicBoolean;
1722
import org.jetbrains.annotations.ApiStatus;
1823
import org.jetbrains.annotations.NotNull;
1924

@@ -30,21 +35,36 @@ public class ScreenshotUtils {
3035
activity, AndroidMainThreadChecker.getInstance(), logger, buildInfoProvider);
3136
}
3237

38+
@SuppressLint("NewApi")
3339
public static @Nullable byte[] takeScreenshot(
3440
final @NotNull Activity activity,
3541
final @NotNull IMainThreadChecker mainThreadChecker,
3642
final @NotNull ILogger logger,
3743
final @NotNull BuildInfoProvider buildInfoProvider) {
3844

39-
if (!isActivityValid(activity, buildInfoProvider)
40-
|| activity.getWindow() == null
41-
|| activity.getWindow().getDecorView() == null
42-
|| activity.getWindow().getDecorView().getRootView() == null) {
45+
if (!isActivityValid(activity, buildInfoProvider)) {
4346
logger.log(SentryLevel.DEBUG, "Activity isn't valid, not taking screenshot.");
4447
return null;
4548
}
4649

47-
final View view = activity.getWindow().getDecorView().getRootView();
50+
final @Nullable Window window = activity.getWindow();
51+
if (window == null) {
52+
logger.log(SentryLevel.DEBUG, "Activity window is null, not taking screenshot.");
53+
return null;
54+
}
55+
56+
final @Nullable View decorView = window.peekDecorView();
57+
if (decorView == null) {
58+
logger.log(SentryLevel.DEBUG, "DecorView is null, not taking screenshot.");
59+
return null;
60+
}
61+
62+
final @Nullable View view = decorView.getRootView();
63+
if (view == null) {
64+
logger.log(SentryLevel.DEBUG, "Root view is null, not taking screenshot.");
65+
return null;
66+
}
67+
4868
if (view.getWidth() <= 0 || view.getHeight() <= 0) {
4969
logger.log(SentryLevel.DEBUG, "View's width and height is zeroed, not taking screenshot.");
5070
return null;
@@ -55,20 +75,58 @@ public class ScreenshotUtils {
5575
final Bitmap bitmap =
5676
Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
5777

58-
final Canvas canvas = new Canvas(bitmap);
59-
if (mainThreadChecker.isMainThread()) {
60-
view.draw(canvas);
61-
} else {
62-
final @NotNull CountDownLatch latch = new CountDownLatch(1);
63-
activity.runOnUiThread(
64-
() -> {
65-
try {
66-
view.draw(canvas);
78+
final @NotNull CountDownLatch latch = new CountDownLatch(1);
79+
80+
// Use Pixel Copy API on new devices, fallback to canvas rendering on older ones
81+
if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.O) {
82+
83+
final HandlerThread thread = new HandlerThread("SentryScreenshot");
84+
thread.start();
85+
86+
boolean success = false;
87+
try {
88+
final Handler handler = new Handler(thread.getLooper());
89+
final AtomicBoolean copyResultSuccess = new AtomicBoolean(false);
90+
91+
PixelCopy.request(
92+
window,
93+
bitmap,
94+
copyResult -> {
95+
copyResultSuccess.set(copyResult == PixelCopy.SUCCESS);
6796
latch.countDown();
68-
} catch (Throwable e) {
69-
logger.log(SentryLevel.ERROR, "Taking screenshot failed (view.draw).", e);
70-
}
71-
});
97+
},
98+
handler);
99+
100+
success =
101+
latch.await(CAPTURE_TIMEOUT_MS, TimeUnit.MILLISECONDS) && copyResultSuccess.get();
102+
} catch (Throwable e) {
103+
// ignored
104+
logger.log(SentryLevel.ERROR, "Taking screenshot using PixelCopy failed.", e);
105+
} finally {
106+
thread.quit();
107+
}
108+
109+
if (!success) {
110+
return null;
111+
}
112+
} else {
113+
final Canvas canvas = new Canvas(bitmap);
114+
if (mainThreadChecker.isMainThread()) {
115+
view.draw(canvas);
116+
latch.countDown();
117+
} else {
118+
activity.runOnUiThread(
119+
() -> {
120+
try {
121+
view.draw(canvas);
122+
} catch (Throwable e) {
123+
logger.log(SentryLevel.ERROR, "Taking screenshot failed (view.draw).", e);
124+
} finally {
125+
latch.countDown();
126+
}
127+
});
128+
}
129+
72130
if (!latch.await(CAPTURE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
73131
return null;
74132
}

sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class ScreenshotEventProcessorTest {
4646
whenever(rootView.height).thenReturn(1)
4747
whenever(view.rootView).thenReturn(rootView)
4848
whenever(window.decorView).thenReturn(view)
49+
whenever(window.peekDecorView()).thenReturn(view)
4950
whenever(activity.window).thenReturn(window)
5051
whenever(activity.runOnUiThread(any())).then {
5152
it.getArgument<Runnable>(0).run()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package io.sentry.android.core.internal.util
2+
3+
import android.app.Activity
4+
import android.os.Build
5+
import android.os.Bundle
6+
import android.view.View
7+
import android.view.Window
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import io.sentry.ILogger
10+
import io.sentry.android.core.BuildInfoProvider
11+
import junit.framework.TestCase.assertNull
12+
import org.junit.runner.RunWith
13+
import org.mockito.kotlin.mock
14+
import org.mockito.kotlin.whenever
15+
import org.robolectric.Robolectric.buildActivity
16+
import org.robolectric.annotation.Config
17+
import org.robolectric.shadows.ShadowPixelCopy
18+
import kotlin.test.Test
19+
import kotlin.test.assertNotNull
20+
21+
@Config(
22+
shadows = [ShadowPixelCopy::class],
23+
sdk = [26, 33]
24+
)
25+
@RunWith(AndroidJUnit4::class)
26+
class ScreenshotUtilTest {
27+
28+
@Test
29+
fun `when window is null, null is returned`() {
30+
val activity = mock<Activity>()
31+
whenever(activity.isFinishing).thenReturn(false)
32+
whenever(activity.isDestroyed).thenReturn(false)
33+
34+
val data =
35+
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
36+
assertNull(data)
37+
}
38+
39+
@Test
40+
fun `when decorView is null, null is returned`() {
41+
val activity = mock<Activity>()
42+
whenever(activity.isFinishing).thenReturn(false)
43+
whenever(activity.isDestroyed).thenReturn(false)
44+
whenever(activity.window).thenReturn(mock<Window>())
45+
46+
val data =
47+
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
48+
assertNull(data)
49+
}
50+
51+
@Test
52+
fun `when root view is null, null is returned`() {
53+
val activity = mock<Activity>()
54+
val window = mock<Window>()
55+
val decorView = mock<View>()
56+
whenever(activity.isFinishing).thenReturn(false)
57+
whenever(activity.isDestroyed).thenReturn(false)
58+
whenever(activity.window).thenReturn(window)
59+
60+
whenever(window.peekDecorView()).thenReturn(decorView)
61+
62+
val data =
63+
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
64+
assertNull(data)
65+
}
66+
67+
@Test
68+
fun `when root view has no size, null is returned`() {
69+
val activity = mock<Activity>()
70+
val window = mock<Window>()
71+
val decorView = mock<View>()
72+
val rootView = mock<View>()
73+
whenever(activity.isFinishing).thenReturn(false)
74+
whenever(activity.isDestroyed).thenReturn(false)
75+
whenever(activity.window).thenReturn(window)
76+
77+
whenever(window.peekDecorView()).thenReturn(decorView)
78+
whenever(decorView.rootView).thenReturn(rootView)
79+
80+
whenever(rootView.width).thenReturn(0)
81+
whenever(rootView.height).thenReturn(0)
82+
83+
val data =
84+
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
85+
assertNull(data)
86+
}
87+
88+
@Test
89+
fun `capturing screenshots works for Android O using PixelCopy API`() {
90+
val controller = buildActivity(ExampleActivity::class.java, null).setup()
91+
controller.create().start().resume()
92+
93+
val logger = mock<ILogger>()
94+
val buildInfoProvider = mock<BuildInfoProvider>()
95+
whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O)
96+
97+
val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider)
98+
assertNotNull(data)
99+
}
100+
101+
@Test
102+
fun `capturing screenshots works pre Android O using Canvas API`() {
103+
val controller = buildActivity(ExampleActivity::class.java, null).setup()
104+
controller.create().start().resume()
105+
106+
val logger = mock<ILogger>()
107+
val buildInfoProvider = mock<BuildInfoProvider>()
108+
whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N)
109+
110+
val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider)
111+
assertNotNull(data)
112+
}
113+
}
114+
115+
class ExampleActivity : Activity() {
116+
override fun onCreate(savedInstanceState: Bundle?) {
117+
super.onCreate(savedInstanceState)
118+
setContentView(View(this))
119+
}
120+
}

0 commit comments

Comments
 (0)