Skip to content

Commit e17ab11

Browse files
authored
Feedback UI: Use Image Picker libraries from integrations (#4524)
* Disable bouncing * Add modal ui appearance * Update snapshot tests * Fix bottom margin * Fix sheet height * Remove extra modal border * Do not expose modal styles * Animate background color * Avoid keyboard in modal * Use Image Picker interface matching `expo-image-picker` and `react-native-image-picker` * Update samples to pass the ImagePicker library implementation * Get image data from uri * Add early return and dev note * Adds tests * Adds sample expo plugin configuration * Update media type for expo * Update media type for rn * Add native implementation for getDataFromUri * Bumped to the latest react-native-image-picker version 8 * Add missing null in return type
1 parent 22cde46 commit e17ab11

File tree

17 files changed

+257
-50
lines changed

17 files changed

+257
-50
lines changed

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

+35
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import android.content.pm.PackageInfo;
1111
import android.content.pm.PackageManager;
1212
import android.content.res.AssetManager;
13+
import android.net.Uri;
1314
import android.util.SparseIntArray;
1415
import androidx.core.app.FrameMetricsAggregator;
1516
import androidx.fragment.app.FragmentActivity;
@@ -72,6 +73,7 @@
7273
import io.sentry.vendor.Base64;
7374
import java.io.BufferedInputStream;
7475
import java.io.BufferedReader;
76+
import java.io.ByteArrayOutputStream;
7577
import java.io.File;
7678
import java.io.FileNotFoundException;
7779
import java.io.FileReader;
@@ -970,6 +972,39 @@ public String fetchNativePackageName() {
970972
return packageInfo.packageName;
971973
}
972974

975+
public void getDataFromUri(String uri, Promise promise) {
976+
try {
977+
Uri contentUri = Uri.parse(uri);
978+
try (InputStream is =
979+
getReactApplicationContext().getContentResolver().openInputStream(contentUri)) {
980+
if (is == null) {
981+
String msg = "File not found for uri: " + uri;
982+
logger.log(SentryLevel.ERROR, msg);
983+
promise.reject(new Exception(msg));
984+
return;
985+
}
986+
987+
ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
988+
int bufferSize = 1024;
989+
byte[] buffer = new byte[bufferSize];
990+
int len;
991+
while ((len = is.read(buffer)) != -1) {
992+
byteBuffer.write(buffer, 0, len);
993+
}
994+
byte[] byteArray = byteBuffer.toByteArray();
995+
WritableArray jsArray = Arguments.createArray();
996+
for (byte b : byteArray) {
997+
jsArray.pushInt(b & 0xFF);
998+
}
999+
promise.resolve(jsArray);
1000+
}
1001+
} catch (IOException e) {
1002+
String msg = "Error reading uri: " + uri + ": " + e.getMessage();
1003+
logger.log(SentryLevel.ERROR, msg);
1004+
promise.reject(new Exception(msg));
1005+
}
1006+
}
1007+
9731008
public void crashedLastRun(Promise promise) {
9741009
promise.resolve(Sentry.isCrashedLastRun());
9751010
}

packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java

+5
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,9 @@ public void crashedLastRun(Promise promise) {
177177
public void getNewScreenTimeToDisplay(Promise promise) {
178178
this.impl.getNewScreenTimeToDisplay(promise);
179179
}
180+
181+
@Override
182+
public void getDataFromUri(String uri, Promise promise) {
183+
this.impl.getDataFromUri(uri, promise);
184+
}
180185
}

packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java

+5
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ public String fetchNativePackageName() {
152152
return this.impl.fetchNativePackageName();
153153
}
154154

155+
@ReactMethod
156+
public void getDataFromUri(String uri, Promise promise) {
157+
this.impl.getDataFromUri(uri, promise);
158+
}
159+
155160
@ReactMethod(isBlockingSynchronousMethod = true)
156161
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
157162
// Not used on Android

packages/core/ios/RNSentry.mm

+29
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,35 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray<NSNumber *> *)instructionsAd
745745
#endif
746746
}
747747

748+
RCT_EXPORT_METHOD(getDataFromUri
749+
: (NSString *_Nonnull)uri resolve
750+
: (RCTPromiseResolveBlock)resolve rejecter
751+
: (RCTPromiseRejectBlock)reject)
752+
{
753+
#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST
754+
NSURL *fileURL = [NSURL URLWithString:uri];
755+
if (![fileURL isFileURL]) {
756+
reject(@"SentryReactNative", @"The provided URI is not a valid file:// URL", nil);
757+
return;
758+
}
759+
NSError *error = nil;
760+
NSData *fileData = [NSData dataWithContentsOfURL:fileURL options:0 error:&error];
761+
if (error || !fileData) {
762+
reject(@"SentryReactNative", @"Failed to read file data", error);
763+
return;
764+
}
765+
NSMutableArray *byteArray = [NSMutableArray arrayWithCapacity:fileData.length];
766+
const unsigned char *bytes = (const unsigned char *)fileData.bytes;
767+
768+
for (NSUInteger i = 0; i < fileData.length; i++) {
769+
[byteArray addObject:@(bytes[i])];
770+
}
771+
resolve(byteArray);
772+
#else
773+
resolve(nil);
774+
#endif
775+
}
776+
748777
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getCurrentReplayId)
749778
{
750779
#if SENTRY_TARGET_REPLAY_SUPPORTED

packages/core/src/js/NativeRNSentry.ts

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface Spec extends TurboModule {
4848
captureReplay(isHardCrash: boolean): Promise<string | undefined | null>;
4949
getCurrentReplayId(): string | undefined | null;
5050
crashedLastRun(): Promise<boolean | undefined | null>;
51+
getDataFromUri(uri: string): Promise<number[]>;
5152
}
5253

5354
export type NativeStackFrame = {

packages/core/src/js/feedback/FeedbackForm.tsx

+47-7
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import {
1717
View
1818
} from 'react-native';
1919

20+
import { NATIVE } from './../wrapper';
2021
import { sentryLogo } from './branding';
2122
import { defaultConfiguration } from './defaults';
2223
import defaultStyles from './FeedbackForm.styles';
23-
import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles,FeedbackGeneralConfiguration, FeedbackTextConfiguration } from './FeedbackForm.types';
24+
import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles, FeedbackGeneralConfiguration, FeedbackTextConfiguration, ImagePickerConfiguration } from './FeedbackForm.types';
2425
import { isValidEmail } from './utils';
2526

2627
/**
@@ -100,12 +101,50 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
100101
}
101102
};
102103

103-
public onScreenshotButtonPress: () => void = () => {
104+
public onScreenshotButtonPress: () => void = async () => {
104105
if (!this.state.filename && !this.state.attachment) {
105-
const { onAddScreenshot } = { ...defaultConfiguration, ...this.props };
106-
onAddScreenshot((filename: string, attachement: Uint8Array) => {
107-
this.setState({ filename, attachment: attachement });
108-
});
106+
const imagePickerConfiguration: ImagePickerConfiguration = this.props;
107+
if (imagePickerConfiguration.imagePicker) {
108+
const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync
109+
// expo-image-picker library is available
110+
? () => imagePickerConfiguration.imagePicker.launchImageLibraryAsync({ mediaTypes: ['images'] })
111+
// react-native-image-picker library is available
112+
: imagePickerConfiguration.imagePicker.launchImageLibrary
113+
? () => imagePickerConfiguration.imagePicker.launchImageLibrary({ mediaType: 'photo' })
114+
: null;
115+
if (!launchImageLibrary) {
116+
logger.warn('No compatible image picker library found. Please provide a valid image picker library.');
117+
if (__DEV__) {
118+
Alert.alert(
119+
'Development note',
120+
'No compatible image picker library found. Please provide a compatible version of `expo-image-picker` or `react-native-image-picker`.',
121+
);
122+
}
123+
return;
124+
}
125+
126+
const result = await launchImageLibrary();
127+
if (result.assets && result.assets.length > 0) {
128+
const filename = result.assets[0].fileName;
129+
const imageUri = result.assets[0].uri;
130+
NATIVE.getDataFromUri(imageUri).then((data) => {
131+
if (data != null) {
132+
this.setState({ filename, attachment: data });
133+
} else {
134+
logger.error('Failed to read image data from uri:', imageUri);
135+
}
136+
})
137+
.catch((error) => {
138+
logger.error('Failed to read image data from uri:', imageUri, 'error: ', error);
139+
});
140+
}
141+
} else {
142+
// Defaulting to the onAddScreenshot callback
143+
const { onAddScreenshot } = { ...defaultConfiguration, ...this.props };
144+
onAddScreenshot((filename: string, attachement: Uint8Array) => {
145+
this.setState({ filename, attachment: attachement });
146+
});
147+
}
109148
} else {
110149
this.setState({ filename: undefined, attachment: undefined });
111150
}
@@ -118,6 +157,7 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
118157
const { name, email, description } = this.state;
119158
const { onFormClose } = this.props;
120159
const config: FeedbackGeneralConfiguration = this.props;
160+
const imagePickerConfiguration: ImagePickerConfiguration = this.props;
121161
const text: FeedbackTextConfiguration = this.props;
122162
const styles: FeedbackFormStyles = { ...defaultStyles, ...this.props.styles };
123163
const onCancel = (): void => {
@@ -191,7 +231,7 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
191231
onChangeText={(value) => this.setState({ description: value })}
192232
multiline
193233
/>
194-
{config.enableScreenshot && (
234+
{(config.enableScreenshot || imagePickerConfiguration.imagePicker) && (
195235
<TouchableOpacity style={styles.screenshotButton} onPress={this.onScreenshotButtonPress}>
196236
<Text style={styles.screenshotText}>
197237
{!this.state.filename && !this.state.attachment

packages/core/src/js/feedback/FeedbackForm.types.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import type { ImageStyle, TextStyle, ViewStyle } from 'react-native';
44
/**
55
* The props for the feedback form
66
*/
7-
export interface FeedbackFormProps extends FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackCallbacks {
7+
export interface FeedbackFormProps
8+
extends FeedbackGeneralConfiguration,
9+
FeedbackTextConfiguration,
10+
FeedbackCallbacks,
11+
ImagePickerConfiguration {
812
styles?: FeedbackFormStyles;
913
}
1014

@@ -187,6 +191,38 @@ export interface FeedbackCallbacks {
187191
onFormSubmitted?: () => void;
188192
}
189193

194+
/**
195+
* Image Picker configuration interface compatible with:
196+
* - `react-native-image-picker`: 7.2, 8.0
197+
* - `expo-image-picker`: 16.0`
198+
*/
199+
export interface ImagePickerConfiguration {
200+
imagePicker?: ImagePicker;
201+
}
202+
203+
interface ImagePickerResponse {
204+
assets?: ImagePickerAsset[];
205+
}
206+
207+
interface ImagePickerAsset {
208+
fileName?: string;
209+
uri?: string;
210+
}
211+
212+
interface ExpoImageLibraryOptions {
213+
mediaTypes?: 'images'[];
214+
}
215+
216+
interface ReactNativeImageLibraryOptions {
217+
mediaType: 'photo';
218+
}
219+
220+
export interface ImagePicker {
221+
launchImageLibraryAsync?: (options?: ExpoImageLibraryOptions) => Promise<ImagePickerResponse>;
222+
223+
launchImageLibrary?: (options: ReactNativeImageLibraryOptions) => Promise<ImagePickerResponse>;
224+
}
225+
190226
/**
191227
* The styles for the feedback form
192228
*/

packages/core/src/js/wrapper.ts

+15
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ interface SentryNativeWrapper {
120120

121121
crashedLastRun(): Promise<boolean | null>;
122122
getNewScreenTimeToDisplay(): Promise<number | null | undefined>;
123+
124+
getDataFromUri(uri: string): Promise<Uint8Array | null>;
123125
}
124126

125127
const EOL = utf8ToBytes('\n');
@@ -702,6 +704,19 @@ export const NATIVE: SentryNativeWrapper = {
702704
return RNSentry.getNewScreenTimeToDisplay();
703705
},
704706

707+
async getDataFromUri(uri: string): Promise<Uint8Array | null> {
708+
if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
709+
return null;
710+
}
711+
try {
712+
const data: number[] = await RNSentry.getDataFromUri(uri);
713+
return new Uint8Array(data);
714+
} catch (error) {
715+
logger.error('Error:', error);
716+
return null;
717+
}
718+
},
719+
705720
/**
706721
* Gets the event from envelopeItem and applies the level filter to the selected event.
707722
* @param data An envelope item containing the event.

packages/core/test/feedback/FeedbackForm.test.tsx

+36-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as React from 'react';
44
import { Alert } from 'react-native';
55

66
import { FeedbackForm } from '../../src/js/feedback/FeedbackForm';
7-
import type { FeedbackFormProps, FeedbackFormStyles } from '../../src/js/feedback/FeedbackForm.types';
7+
import type { FeedbackFormProps, FeedbackFormStyles, ImagePicker } from '../../src/js/feedback/FeedbackForm.types';
88

99
const mockOnFormClose = jest.fn();
1010
const mockOnAddScreenshot = jest.fn();
@@ -303,7 +303,7 @@ describe('FeedbackForm', () => {
303303
});
304304
});
305305

306-
it('calls onAddScreenshot when the screenshot button is pressed', async () => {
306+
it('calls onAddScreenshot when the screenshot button is pressed and no image picker library is integrated', async () => {
307307
const { getByText } = render(<FeedbackForm {...defaultProps} enableScreenshot={true} />);
308308

309309
fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));
@@ -313,6 +313,40 @@ describe('FeedbackForm', () => {
313313
});
314314
});
315315

316+
it('calls launchImageLibraryAsync when the expo-image-picker library is integrated', async () => {
317+
const mockLaunchImageLibrary = jest.fn().mockResolvedValue({
318+
assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }],
319+
});
320+
const mockImagePicker: jest.Mocked<ImagePicker> = {
321+
launchImageLibraryAsync: mockLaunchImageLibrary,
322+
};
323+
324+
const { getByText } = render(<FeedbackForm {...defaultProps} imagePicker={ mockImagePicker } />);
325+
326+
fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));
327+
328+
await waitFor(() => {
329+
expect(mockLaunchImageLibrary).toHaveBeenCalled();
330+
});
331+
});
332+
333+
it('calls launchImageLibrary when the react-native-image-picker library is integrated', async () => {
334+
const mockLaunchImageLibrary = jest.fn().mockResolvedValue({
335+
assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }],
336+
});
337+
const mockImagePicker: jest.Mocked<ImagePicker> = {
338+
launchImageLibrary: mockLaunchImageLibrary,
339+
};
340+
341+
const { getByText } = render(<FeedbackForm {...defaultProps} imagePicker={ mockImagePicker } />);
342+
343+
fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));
344+
345+
await waitFor(() => {
346+
expect(mockLaunchImageLibrary).toHaveBeenCalled();
347+
});
348+
});
349+
316350
it('calls onFormClose when the cancel button is pressed', () => {
317351
const { getByText } = render(<FeedbackForm {...defaultProps} />);
318352

packages/core/test/mockWrapper.ts

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const NATIVE: MockInterface<NativeType> = {
5959

6060
crashedLastRun: jest.fn(),
6161
getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42),
62+
getDataFromUri: jest.fn(),
6263
};
6364

6465
NATIVE.isNativeAvailable.mockReturnValue(true);

samples/expo/app.json

+6
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
"organization": "sentry-sdks"
4848
}
4949
],
50+
[
51+
"expo-image-picker",
52+
{
53+
"photosPermission": "The app accesses your photos to let you share them with your friends."
54+
}
55+
],
5056
[
5157
"expo-router",
5258
{

samples/expo/app/(tabs)/index.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ export default function TabOneScreen() {
5757
Sentry.nativeCrash();
5858
}}
5959
/>
60+
<Button
61+
title="Show feedback form"
62+
onPress={() => {
63+
Sentry.showFeedbackForm();
64+
}}
65+
/>
6066
<Button
6167
title="Set Scope Properties"
6268
onPress={() => {

samples/expo/app/_layout.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ErrorEvent } from '@sentry/core';
1111
import { isExpoGo } from '../utils/isExpoGo';
1212
import { LogBox } from 'react-native';
1313
import { isWeb } from '../utils/isWeb';
14+
import * as ImagePicker from 'expo-image-picker';
1415

1516
export {
1617
// Catch any errors thrown by the Layout component.
@@ -57,6 +58,9 @@ Sentry.init({
5758
}),
5859
navigationIntegration,
5960
Sentry.reactNativeTracingIntegration(),
61+
Sentry.feedbackIntegration({
62+
imagePicker: ImagePicker,
63+
}),
6064
);
6165
if (isWeb()) {
6266
integrations.push(Sentry.browserReplayIntegration());

0 commit comments

Comments
 (0)