Skip to content

Feedback UI: Use Image Picker libraries from integrations #4524

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fe3dc15
Disable bouncing
antonis Jan 30, 2025
72eef2d
Add modal ui appearance
antonis Jan 30, 2025
ccc808b
Update snapshot tests
antonis Jan 30, 2025
fd47fd2
Fix bottom margin
antonis Jan 30, 2025
dcc5d3b
Merge branch 'feedback-ui' into antonis/feedback-modal-ui
antonis Jan 31, 2025
9ecd8a2
Fix sheet height
antonis Jan 31, 2025
05f94f8
Remove extra modal border
antonis Jan 31, 2025
d88a599
Do not expose modal styles
antonis Jan 31, 2025
ce1de86
Animate background color
antonis Jan 31, 2025
a7a4e56
Avoid keyboard in modal
antonis Jan 31, 2025
8779886
Merge branch 'feedback-ui' into antonis/feedback-modal-ui
antonis Feb 3, 2025
ae80f7d
Merge branch 'feedback-ui' into antonis/feedback-modal-ui
antonis Feb 7, 2025
488658e
Merge branch 'feedback-ui' into antonis/feedback-modal-ui
antonis Feb 10, 2025
c4d502e
Use Image Picker interface matching `expo-image-picker` and `react-na…
antonis Feb 11, 2025
1b74b45
Update samples to pass the ImagePicker library implementation
antonis Feb 11, 2025
1d8a0db
Merge branch 'feedback-ui' into antonis/feedback-ui-imagepicker-integ…
antonis Feb 11, 2025
a0f4a77
Get image data from uri
antonis Feb 11, 2025
357dea8
Add early return and dev note
antonis Feb 11, 2025
192220b
Adds tests
antonis Feb 11, 2025
c3991ff
Adds sample expo plugin configuration
antonis Feb 11, 2025
91e96de
Merge branch 'feedback-ui' into antonis/feedback-ui-imagepicker-integ…
antonis Feb 11, 2025
6666cf6
Update media type for expo
antonis Feb 12, 2025
d1e5107
Update media type for rn
antonis Feb 12, 2025
d780fc1
Add native implementation for getDataFromUri
antonis Feb 13, 2025
85dff80
Bumped to the latest react-native-image-picker version 8
antonis Feb 13, 2025
e92fea6
Add missing null in return type
antonis Feb 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.net.Uri;
import android.util.SparseIntArray;
import androidx.core.app.FrameMetricsAggregator;
import androidx.fragment.app.FragmentActivity;
Expand Down Expand Up @@ -72,6 +73,7 @@
import io.sentry.vendor.Base64;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
Expand Down Expand Up @@ -970,6 +972,39 @@ public String fetchNativePackageName() {
return packageInfo.packageName;
}

public void getDataFromUri(String uri, Promise promise) {
try {
Uri contentUri = Uri.parse(uri);
try (InputStream is =
getReactApplicationContext().getContentResolver().openInputStream(contentUri)) {
if (is == null) {
String msg = "File not found for uri: " + uri;
logger.log(SentryLevel.ERROR, msg);
promise.reject(new Exception(msg));
return;
}

ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int len;
while ((len = is.read(buffer)) != -1) {
byteBuffer.write(buffer, 0, len);
}
byte[] byteArray = byteBuffer.toByteArray();
WritableArray jsArray = Arguments.createArray();
for (byte b : byteArray) {
jsArray.pushInt(b & 0xFF);
}
promise.resolve(jsArray);
}
} catch (IOException e) {
String msg = "Error reading uri: " + uri + ": " + e.getMessage();
logger.log(SentryLevel.ERROR, msg);
promise.reject(new Exception(msg));
}
}

public void crashedLastRun(Promise promise) {
promise.resolve(Sentry.isCrashedLastRun());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,9 @@ public void crashedLastRun(Promise promise) {
public void getNewScreenTimeToDisplay(Promise promise) {
this.impl.getNewScreenTimeToDisplay(promise);
}

@Override
public void getDataFromUri(String uri, Promise promise) {
this.impl.getDataFromUri(uri, promise);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ public String fetchNativePackageName() {
return this.impl.fetchNativePackageName();
}

@ReactMethod
public void getDataFromUri(String uri, Promise promise) {
this.impl.getDataFromUri(uri, promise);
}

@ReactMethod(isBlockingSynchronousMethod = true)
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
// Not used on Android
Expand Down
29 changes: 29 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,35 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray<NSNumber *> *)instructionsAd
#endif
}

RCT_EXPORT_METHOD(getDataFromUri
: (NSString *_Nonnull)uri resolve
: (RCTPromiseResolveBlock)resolve rejecter
: (RCTPromiseRejectBlock)reject)
{
#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST
NSURL *fileURL = [NSURL URLWithString:uri];
if (![fileURL isFileURL]) {
reject(@"SentryReactNative", @"The provided URI is not a valid file:// URL", nil);
return;
}
NSError *error = nil;
NSData *fileData = [NSData dataWithContentsOfURL:fileURL options:0 error:&error];
if (error || !fileData) {
reject(@"SentryReactNative", @"Failed to read file data", error);
return;
}
NSMutableArray *byteArray = [NSMutableArray arrayWithCapacity:fileData.length];
const unsigned char *bytes = (const unsigned char *)fileData.bytes;

for (NSUInteger i = 0; i < fileData.length; i++) {
[byteArray addObject:@(bytes[i])];
}
resolve(byteArray);
#else
resolve(nil);
#endif
}

RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getCurrentReplayId)
{
#if SENTRY_TARGET_REPLAY_SUPPORTED
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface Spec extends TurboModule {
captureReplay(isHardCrash: boolean): Promise<string | undefined | null>;
getCurrentReplayId(): string | undefined | null;
crashedLastRun(): Promise<boolean | undefined | null>;
getDataFromUri(uri: string): Promise<number[]>;
}

export type NativeStackFrame = {
Expand Down
54 changes: 47 additions & 7 deletions packages/core/src/js/feedback/FeedbackForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import {
View
} from 'react-native';

import { NATIVE } from './../wrapper';
import { sentryLogo } from './branding';
import { defaultConfiguration } from './defaults';
import defaultStyles from './FeedbackForm.styles';
import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles,FeedbackGeneralConfiguration, FeedbackTextConfiguration } from './FeedbackForm.types';
import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles, FeedbackGeneralConfiguration, FeedbackTextConfiguration, ImagePickerConfiguration } from './FeedbackForm.types';
import { isValidEmail } from './utils';

/**
Expand Down Expand Up @@ -100,12 +101,50 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
}
};

public onScreenshotButtonPress: () => void = () => {
public onScreenshotButtonPress: () => void = async () => {
if (!this.state.filename && !this.state.attachment) {
const { onAddScreenshot } = { ...defaultConfiguration, ...this.props };
onAddScreenshot((filename: string, attachement: Uint8Array) => {
this.setState({ filename, attachment: attachement });
});
const imagePickerConfiguration: ImagePickerConfiguration = this.props;
if (imagePickerConfiguration.imagePicker) {
const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync
// expo-image-picker library is available
? () => imagePickerConfiguration.imagePicker.launchImageLibraryAsync({ mediaTypes: ['images'] })
// react-native-image-picker library is available
: imagePickerConfiguration.imagePicker.launchImageLibrary
? () => imagePickerConfiguration.imagePicker.launchImageLibrary({ mediaType: 'photo' })
: null;
if (!launchImageLibrary) {
logger.warn('No compatible image picker library found. Please provide a valid image picker library.');
if (__DEV__) {
Alert.alert(
'Development note',
'No compatible image picker library found. Please provide a compatible version of `expo-image-picker` or `react-native-image-picker`.',
);
}
return;
}

const result = await launchImageLibrary();
if (result.assets && result.assets.length > 0) {
const filename = result.assets[0].fileName;
const imageUri = result.assets[0].uri;
NATIVE.getDataFromUri(imageUri).then((data) => {
if (data != null) {
this.setState({ filename, attachment: data });
} else {
logger.error('Failed to read image data from uri:', imageUri);
}
})
.catch((error) => {
logger.error('Failed to read image data from uri:', imageUri, 'error: ', error);
});
}
} else {
// Defaulting to the onAddScreenshot callback
const { onAddScreenshot } = { ...defaultConfiguration, ...this.props };
onAddScreenshot((filename: string, attachement: Uint8Array) => {
this.setState({ filename, attachment: attachement });
});
}
} else {
this.setState({ filename: undefined, attachment: undefined });
}
Expand All @@ -118,6 +157,7 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
const { name, email, description } = this.state;
const { onFormClose } = this.props;
const config: FeedbackGeneralConfiguration = this.props;
const imagePickerConfiguration: ImagePickerConfiguration = this.props;
const text: FeedbackTextConfiguration = this.props;
const styles: FeedbackFormStyles = { ...defaultStyles, ...this.props.styles };
const onCancel = (): void => {
Expand Down Expand Up @@ -191,7 +231,7 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
onChangeText={(value) => this.setState({ description: value })}
multiline
/>
{config.enableScreenshot && (
{(config.enableScreenshot || imagePickerConfiguration.imagePicker) && (
<TouchableOpacity style={styles.screenshotButton} onPress={this.onScreenshotButtonPress}>
<Text style={styles.screenshotText}>
{!this.state.filename && !this.state.attachment
Expand Down
38 changes: 37 additions & 1 deletion packages/core/src/js/feedback/FeedbackForm.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { ImageStyle, TextStyle, ViewStyle } from 'react-native';
/**
* The props for the feedback form
*/
export interface FeedbackFormProps extends FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackCallbacks {
export interface FeedbackFormProps
extends FeedbackGeneralConfiguration,
FeedbackTextConfiguration,
FeedbackCallbacks,
ImagePickerConfiguration {
styles?: FeedbackFormStyles;
}

Expand Down Expand Up @@ -187,6 +191,38 @@ export interface FeedbackCallbacks {
onFormSubmitted?: () => void;
}

/**
* Image Picker configuration interface compatible with:
* - `react-native-image-picker`: 7.2, 8.0
* - `expo-image-picker`: 16.0`
*/
export interface ImagePickerConfiguration {
imagePicker?: ImagePicker;
}

interface ImagePickerResponse {
assets?: ImagePickerAsset[];
}

interface ImagePickerAsset {
fileName?: string;
uri?: string;
}

interface ExpoImageLibraryOptions {
mediaTypes?: 'images'[];
}

interface ReactNativeImageLibraryOptions {
mediaType: 'photo';
}

export interface ImagePicker {
launchImageLibraryAsync?: (options?: ExpoImageLibraryOptions) => Promise<ImagePickerResponse>;

launchImageLibrary?: (options: ReactNativeImageLibraryOptions) => Promise<ImagePickerResponse>;
}

/**
* The styles for the feedback form
*/
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ interface SentryNativeWrapper {

crashedLastRun(): Promise<boolean | null>;
getNewScreenTimeToDisplay(): Promise<number | null | undefined>;

getDataFromUri(uri: string): Promise<Uint8Array | null>;
}

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

async getDataFromUri(uri: string): Promise<Uint8Array | null> {
if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
return null;
}
try {
const data: number[] = await RNSentry.getDataFromUri(uri);
return new Uint8Array(data);
} catch (error) {
logger.error('Error:', error);
return null;
}
},

/**
* Gets the event from envelopeItem and applies the level filter to the selected event.
* @param data An envelope item containing the event.
Expand Down
38 changes: 36 additions & 2 deletions packages/core/test/feedback/FeedbackForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as React from 'react';
import { Alert } from 'react-native';

import { FeedbackForm } from '../../src/js/feedback/FeedbackForm';
import type { FeedbackFormProps, FeedbackFormStyles } from '../../src/js/feedback/FeedbackForm.types';
import type { FeedbackFormProps, FeedbackFormStyles, ImagePicker } from '../../src/js/feedback/FeedbackForm.types';

const mockOnFormClose = jest.fn();
const mockOnAddScreenshot = jest.fn();
Expand Down Expand Up @@ -303,7 +303,7 @@ describe('FeedbackForm', () => {
});
});

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

fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));
Expand All @@ -313,6 +313,40 @@ describe('FeedbackForm', () => {
});
});

it('calls launchImageLibraryAsync when the expo-image-picker library is integrated', async () => {
const mockLaunchImageLibrary = jest.fn().mockResolvedValue({
assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }],
});
const mockImagePicker: jest.Mocked<ImagePicker> = {
launchImageLibraryAsync: mockLaunchImageLibrary,
};

const { getByText } = render(<FeedbackForm {...defaultProps} imagePicker={ mockImagePicker } />);

fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));

await waitFor(() => {
expect(mockLaunchImageLibrary).toHaveBeenCalled();
});
});

it('calls launchImageLibrary when the react-native-image-picker library is integrated', async () => {
const mockLaunchImageLibrary = jest.fn().mockResolvedValue({
assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }],
});
const mockImagePicker: jest.Mocked<ImagePicker> = {
launchImageLibrary: mockLaunchImageLibrary,
};

const { getByText } = render(<FeedbackForm {...defaultProps} imagePicker={ mockImagePicker } />);

fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));

await waitFor(() => {
expect(mockLaunchImageLibrary).toHaveBeenCalled();
});
});

it('calls onFormClose when the cancel button is pressed', () => {
const { getByText } = render(<FeedbackForm {...defaultProps} />);

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/mockWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const NATIVE: MockInterface<NativeType> = {

crashedLastRun: jest.fn(),
getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42),
getDataFromUri: jest.fn(),
};

NATIVE.isNativeAvailable.mockReturnValue(true);
Expand Down
6 changes: 6 additions & 0 deletions samples/expo/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
"organization": "sentry-sdks"
}
],
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to let you share them with your friends."
}
],
[
"expo-router",
{
Expand Down
6 changes: 6 additions & 0 deletions samples/expo/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export default function TabOneScreen() {
Sentry.nativeCrash();
}}
/>
<Button
title="Show feedback form"
onPress={() => {
Sentry.showFeedbackForm();
}}
/>
<Button
title="Set Scope Properties"
onPress={() => {
Expand Down
4 changes: 4 additions & 0 deletions samples/expo/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ErrorEvent } from '@sentry/core';
import { isExpoGo } from '../utils/isExpoGo';
import { LogBox } from 'react-native';
import { isWeb } from '../utils/isWeb';
import * as ImagePicker from 'expo-image-picker';

export {
// Catch any errors thrown by the Layout component.
Expand Down Expand Up @@ -57,6 +58,9 @@ Sentry.init({
}),
navigationIntegration,
Sentry.reactNativeTracingIntegration(),
Sentry.feedbackIntegration({
imagePicker: ImagePicker,
}),
);
if (isWeb()) {
integrations.push(Sentry.browserReplayIntegration());
Expand Down
Loading
Loading