Skip to content

Commit ca810b9

Browse files
krystofwoldrichPaitoAndersonmarandaneto
authored
feat: Screenshots (#2610)
Co-authored-by: Paito Anderson <pj.paito@gmail.com> Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com>
1 parent 4835cf8 commit ca810b9

File tree

12 files changed

+231
-24
lines changed

12 files changed

+231
-24
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Screenshots ([#2610](https://github.com/getsentry/sentry-react-native/pull/2610))
8+
39
## 4.10.1
410

511
### Fixes

android/src/main/java/io/sentry/react/RNSentryModule.java

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.sentry.react;
22

3+
import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot;
4+
35
import android.app.Activity;
46
import android.content.Context;
57
import android.content.pm.PackageInfo;
@@ -17,7 +19,10 @@
1719
import com.facebook.react.bridge.ReadableArray;
1820
import com.facebook.react.bridge.ReadableMap;
1921
import com.facebook.react.bridge.ReadableMapKeySetIterator;
22+
import com.facebook.react.bridge.WritableArray;
2023
import com.facebook.react.bridge.WritableMap;
24+
import com.facebook.react.bridge.WritableNativeArray;
25+
import com.facebook.react.bridge.WritableNativeMap;
2126
import com.facebook.react.module.annotations.ReactModule;
2227

2328
import java.io.BufferedInputStream;
@@ -31,20 +36,23 @@
3136
import java.util.List;
3237
import java.util.Map;
3338
import java.util.UUID;
34-
import java.util.logging.Level;
35-
import java.util.logging.Logger;
3639

3740
import io.sentry.Breadcrumb;
3841
import io.sentry.HubAdapter;
42+
import io.sentry.ILogger;
3943
import io.sentry.Integration;
4044
import io.sentry.Sentry;
4145
import io.sentry.SentryEvent;
4246
import io.sentry.SentryLevel;
4347
import io.sentry.UncaughtExceptionHandlerIntegration;
4448
import io.sentry.android.core.AnrIntegration;
4549
import io.sentry.android.core.AppStartState;
50+
import io.sentry.android.core.BuildInfoProvider;
51+
import io.sentry.android.core.CurrentActivityHolder;
4652
import io.sentry.android.core.NdkIntegration;
53+
import io.sentry.android.core.ScreenshotEventProcessor;
4754
import io.sentry.android.core.SentryAndroid;
55+
import io.sentry.android.core.AndroidLogger;
4856
import io.sentry.protocol.SdkVersion;
4957
import io.sentry.protocol.SentryException;
5058
import io.sentry.protocol.SentryPackage;
@@ -55,13 +63,15 @@ public class RNSentryModule extends ReactContextBaseJavaModule {
5563

5664
public static final String NAME = "RNSentry";
5765

58-
private static final Logger logger = Logger.getLogger("react-native-sentry");
66+
private static final ILogger logger = new AndroidLogger(NAME);
67+
private static final BuildInfoProvider buildInfo = new BuildInfoProvider(logger);
5968
private static final String modulesPath = "modules.json";
6069
private static final Charset UTF_8 = Charset.forName("UTF-8");
6170

6271
private final PackageInfo packageInfo;
6372
private FrameMetricsAggregator frameMetricsAggregator = null;
6473
private boolean androidXAvailable;
74+
private ScreenshotEventProcessor screenshotEventProcessor;
6575

6676
private static boolean didFetchAppStart;
6777

@@ -86,11 +96,10 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
8696
SentryAndroid.init(this.getReactApplicationContext(), options -> {
8797
if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) {
8898
options.setDebug(true);
89-
logger.setLevel(Level.INFO);
9099
}
91100
if (rnOptions.hasKey("dsn") && rnOptions.getString("dsn") != null) {
92101
String dsn = rnOptions.getString("dsn");
93-
logger.info(String.format("Starting with DSN: '%s'", dsn));
102+
logger.log(SentryLevel.INFO, String.format("Starting with DSN: '%s'", dsn));
94103
options.setDsn(dsn);
95104
} else {
96105
// SentryAndroid needs an empty string fallback for the dsn.
@@ -134,6 +143,9 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
134143
// by default we hide.
135144
options.setAttachThreads(rnOptions.getBoolean("attachThreads"));
136145
}
146+
if (rnOptions.hasKey("attachScreenshot")) {
147+
options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot"));
148+
}
137149
if (rnOptions.hasKey("sendDefaultPii")) {
138150
options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii"));
139151
}
@@ -169,8 +181,13 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
169181
}
170182
}
171183
}
184+
logger.log(SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations()));
172185

173-
logger.info(String.format("Native Integrations '%s'", options.getIntegrations()));
186+
final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance();
187+
final Activity currentActivity = getCurrentActivity();
188+
if (currentActivity != null) {
189+
currentActivityHolder.setActivity(currentActivity);
190+
}
174191
});
175192

176193
promise.resolve(true);
@@ -195,7 +212,7 @@ public void fetchModules(Promise promise) {
195212
} catch (FileNotFoundException e) {
196213
promise.resolve(null);
197214
} catch (Throwable e) {
198-
logger.warning("Fetching JS Modules failed.");
215+
logger.log(SentryLevel.WARNING, "Fetching JS Modules failed.");
199216
promise.resolve(null);
200217
}
201218
}
@@ -216,10 +233,10 @@ public void fetchNativeAppStart(Promise promise) {
216233
final Boolean isColdStart = appStartInstance.isColdStart();
217234

218235
if (appStartTime == null) {
219-
logger.warning("App start won't be sent due to missing appStartTime.");
236+
logger.log(SentryLevel.WARNING, "App start won't be sent due to missing appStartTime.");
220237
promise.resolve(null);
221238
} else if (isColdStart == null) {
222-
logger.warning("App start won't be sent due to missing isColdStart.");
239+
logger.log(SentryLevel.WARNING, "App start won't be sent due to missing isColdStart.");
223240
promise.resolve(null);
224241
} else {
225242
final double appStartTimestamp = (double) appStartTime.getTime();
@@ -285,7 +302,7 @@ public void fetchNativeFrames(Promise promise) {
285302

286303
promise.resolve(map);
287304
} catch (Throwable ignored) {
288-
logger.warning("Error fetching native frames.");
305+
logger.log(SentryLevel.WARNING, "Error fetching native frames.");
289306
promise.resolve(null);
290307
}
291308
}
@@ -302,7 +319,7 @@ public void captureEnvelope(ReadableArray rawBytes, ReadableMap options, Promise
302319
final String outboxPath = HubAdapter.getInstance().getOptions().getOutboxPath();
303320

304321
if (outboxPath == null) {
305-
logger.severe(
322+
logger.log(SentryLevel.ERROR,
306323
"Error retrieving outboxPath. Envelope will not be sent. Is the Android SDK initialized?");
307324
} else {
308325
File installation = new File(outboxPath, UUID.randomUUID().toString());
@@ -311,16 +328,47 @@ public void captureEnvelope(ReadableArray rawBytes, ReadableMap options, Promise
311328
}
312329
}
313330
} catch (Throwable ignored) {
314-
logger.severe("Error while writing envelope to outbox.");
331+
logger.log(SentryLevel.ERROR, "Error while writing envelope to outbox.");
315332
}
316333
promise.resolve(true);
317334
}
318335

336+
@ReactMethod
337+
public void captureScreenshot(Promise promise) {
338+
339+
final Activity activity = getCurrentActivity();
340+
if (activity == null) {
341+
logger.log(SentryLevel.WARNING, "CurrentActivity is null, can't capture screenshot.");
342+
promise.resolve(null);
343+
return;
344+
}
345+
346+
final byte[] raw = takeScreenshot(activity, logger, buildInfo);
347+
if (raw == null) {
348+
logger.log(SentryLevel.WARNING, "Screenshot is null, screen was not captured.");
349+
promise.resolve(null);
350+
return;
351+
}
352+
353+
final WritableNativeArray data = new WritableNativeArray();
354+
for (final byte b : raw) {
355+
data.pushInt(b);
356+
}
357+
final WritableMap screenshot = new WritableNativeMap();
358+
screenshot.putString("contentType", "image/png");
359+
screenshot.putArray("data", data);
360+
screenshot.putString("filename", "screenshot.png");
361+
362+
final WritableArray screenshotsArray = new WritableNativeArray();
363+
screenshotsArray.pushMap(screenshot);
364+
promise.resolve(screenshotsArray);
365+
}
366+
319367
private static PackageInfo getPackageInfo(Context ctx) {
320368
try {
321369
return ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0);
322370
} catch (PackageManager.NameNotFoundException e) {
323-
logger.warning("Error getting package info.");
371+
logger.log(SentryLevel.WARNING, "Error getting package info.");
324372
return null;
325373
}
326374
}
@@ -483,17 +531,17 @@ public void enableNativeFramesTracking() {
483531
try {
484532
frameMetricsAggregator.add(currentActivity);
485533

486-
logger.info("FrameMetricsAggregator installed.");
534+
logger.log(SentryLevel.INFO, "FrameMetricsAggregator installed.");
487535
} catch (Throwable ignored) {
488536
// throws ConcurrentModification when calling addOnFrameMetricsAvailableListener
489537
// this is a best effort since we can't reproduce it
490-
logger.severe("Error adding Activity to frameMetricsAggregator.");
538+
logger.log(SentryLevel.ERROR, "Error adding Activity to frameMetricsAggregator.");
491539
}
492540
} else {
493-
logger.info("currentActivity isn't available.");
541+
logger.log(SentryLevel.INFO, "currentActivity isn't available.");
494542
}
495543
} else {
496-
logger.warning("androidx.core' isn't available as a dependency.");
544+
logger.log(SentryLevel.WARNING, "androidx.core' isn't available as a dependency.");
497545
}
498546
}
499547

ios/RNSentry.m

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,35 @@ - (void)setEventEnvironmentTag:(SentryEvent *)event
302302
resolve(@YES);
303303
}
304304

305+
RCT_EXPORT_METHOD(captureScreenshot: (RCTPromiseResolveBlock)resolve
306+
rejecter: (RCTPromiseRejectBlock)reject)
307+
{
308+
NSArray<NSData *>* rawScreenshots = [PrivateSentrySDKOnly captureScreenshots];
309+
NSMutableArray *screenshotsArray = [NSMutableArray arrayWithCapacity:[rawScreenshots count]];
310+
311+
int counter = 1;
312+
for (NSData* raw in rawScreenshots) {
313+
NSMutableArray *screenshot = [NSMutableArray arrayWithCapacity:raw.length];
314+
const char *bytes = [raw bytes];
315+
for (int i = 0; i < [raw length]; i++) {
316+
[screenshot addObject:[[NSNumber alloc] initWithChar:bytes[i]]];
317+
}
318+
319+
NSString* filename = @"screenshot.png";
320+
if (counter > 1) {
321+
filename = [NSString stringWithFormat:@"screenshot-%d.png", counter];
322+
}
323+
[screenshotsArray addObject:@{
324+
@"data": screenshot,
325+
@"contentType": @"image/png",
326+
@"filename": filename,
327+
}];
328+
counter++;
329+
}
330+
331+
resolve(screenshotsArray);
332+
}
333+
305334
RCT_EXPORT_METHOD(setUser:(NSDictionary *)userKeys
306335
otherUserKeys:(NSDictionary *)userDataKeys
307336
)

sample/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ Sentry.init({
6262
// release: 'myapp@1.2.3+1',
6363
// dist: `1`,
6464
attachStacktrace: true,
65+
// Attach screenshots to events.
66+
attachScreenshot: true,
6567
});
6668

6769
const Stack = createStackNavigator();

src/js/client.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils';
1616
// @ts-ignore LogBox introduced in RN 0.63
1717
import { Alert, LogBox, YellowBox } from 'react-native';
1818

19+
import { Screenshot } from './integrations/screenshot';
1920
import { defaultSdkInfo } from './integrations/sdkinfo';
2021
import { ReactNativeClientOptions, ReactNativeTransportOptions } from './options';
2122
import { makeReactNativeTransport } from './transports/native';
@@ -81,8 +82,9 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
8182
/**
8283
* @inheritDoc
8384
*/
84-
public eventFromException(_exception: unknown, _hint?: EventHint): PromiseLike<Event> {
85-
return this._browserClient.eventFromException(_exception, _hint);
85+
public eventFromException(exception: unknown, hint: EventHint = {}): PromiseLike<Event> {
86+
return Screenshot.attachScreenshotToEventHint(hint, this._options)
87+
.then(enrichedHint => this._browserClient.eventFromException(exception, enrichedHint));
8688
}
8789

8890
/**

src/js/definitions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export type NativeDeviceContextsResponse = {
2424
[key: string]: Record<string, unknown>;
2525
};
2626

27+
export interface NativeScreenshot {
28+
data: number[];
29+
contentType: string;
30+
filename: string;
31+
}
32+
2733
interface SerializedObject {
2834
[key: string]: string;
2935
}
@@ -37,6 +43,7 @@ export interface SentryNativeBridgeModule {
3743
store: boolean,
3844
},
3945
): PromiseLike<boolean>;
46+
captureScreenshot(): PromiseLike<NativeScreenshot[]>;
4047
clearBreadcrumbs(): void;
4148
crash(): void;
4249
closeNativeSdk(): PromiseLike<void>;

src/js/integrations/screenshot.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { EventHint, Integration } from '@sentry/types';
2+
import { resolvedSyncPromise } from '@sentry/utils';
3+
4+
import { NATIVE } from '../wrapper';
5+
6+
/** Adds screenshots to error events */
7+
export class Screenshot implements Integration {
8+
/**
9+
* @inheritDoc
10+
*/
11+
public static id: string = 'Screenshot';
12+
13+
/**
14+
* @inheritDoc
15+
*/
16+
public name: string = Screenshot.id;
17+
18+
/**
19+
* If enabled attaches a screenshot to the event hint.
20+
*/
21+
public static attachScreenshotToEventHint(
22+
hint: EventHint,
23+
{ attachScreenshot }: { attachScreenshot?: boolean },
24+
): PromiseLike<EventHint> {
25+
if (!attachScreenshot) {
26+
return resolvedSyncPromise(hint);
27+
}
28+
29+
return NATIVE.captureScreenshot()
30+
.then((screenshots) => {
31+
if (screenshots !== null && screenshots.length > 0) {
32+
hint.attachments = [
33+
...screenshots,
34+
...(hint?.attachments || []),
35+
];
36+
}
37+
return hint;
38+
});
39+
}
40+
41+
/**
42+
* @inheritDoc
43+
*/
44+
// eslint-disable-next-line @typescript-eslint/no-empty-function
45+
public setupOnce(): void {}
46+
}

src/js/options.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export interface BaseReactNativeOptions {
126126
* The max queue size for capping the number of envelopes waiting to be sent by Transport.
127127
*/
128128
maxQueueSize?: number;
129+
130+
/**
131+
* When enabled and a user experiences an error, Sentry provides the ability to take a screenshot and include it as an attachment.
132+
*
133+
* @default false
134+
*/
135+
attachScreenshot?: boolean;
129136
}
130137

131138
export interface ReactNativeTransportOptions extends BrowserTransportOptions {

src/js/sdk.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
Release,
2020
SdkInfo,
2121
} from './integrations';
22+
import { Screenshot } from './integrations/screenshot';
2223
import { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options';
2324
import { ReactNativeScope } from './scope';
2425
import { TouchEventBoundary } from './touchevents';
@@ -132,6 +133,9 @@ export function init(passedOptions: ReactNativeOptions): void {
132133
defaultIntegrations.push(new ReactNativeTracing());
133134
}
134135
}
136+
if (options.attachScreenshot) {
137+
defaultIntegrations.push(new Screenshot());
138+
}
135139
}
136140

137141
options.integrations = getIntegrationsToSetup({

0 commit comments

Comments
 (0)