diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 486ae72c48..38ce09c84c 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -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; @@ -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; @@ -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()); } diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 6ea8542e8b..92ef2c0614 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -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); + } } diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 57fcbf0a73..7896d45fde 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -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 diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 79ff76d0ae..f0ea379cc4 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -745,6 +745,35 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray *)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 diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 2d553e548f..125dc3b082 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -48,6 +48,7 @@ export interface Spec extends TurboModule { captureReplay(isHardCrash: boolean): Promise; getCurrentReplayId(): string | undefined | null; crashedLastRun(): Promise; + getDataFromUri(uri: string): Promise; } export type NativeStackFrame = { diff --git a/packages/core/src/js/feedback/FeedbackForm.tsx b/packages/core/src/js/feedback/FeedbackForm.tsx index 5b8dc28b70..b6aac2f412 100644 --- a/packages/core/src/js/feedback/FeedbackForm.tsx +++ b/packages/core/src/js/feedback/FeedbackForm.tsx @@ -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'; /** @@ -100,12 +101,50 @@ export class FeedbackForm extends React.Component 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 }); } @@ -118,6 +157,7 @@ export class FeedbackForm extends React.Component { @@ -191,7 +231,7 @@ export class FeedbackForm extends React.Component this.setState({ description: value })} multiline /> - {config.enableScreenshot && ( + {(config.enableScreenshot || imagePickerConfiguration.imagePicker) && ( {!this.state.filename && !this.state.attachment diff --git a/packages/core/src/js/feedback/FeedbackForm.types.ts b/packages/core/src/js/feedback/FeedbackForm.types.ts index cffe54447a..b0393c788a 100644 --- a/packages/core/src/js/feedback/FeedbackForm.types.ts +++ b/packages/core/src/js/feedback/FeedbackForm.types.ts @@ -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; } @@ -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; + + launchImageLibrary?: (options: ReactNativeImageLibraryOptions) => Promise; +} + /** * The styles for the feedback form */ diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 4db45f0855..9b37a9b87b 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -120,6 +120,8 @@ interface SentryNativeWrapper { crashedLastRun(): Promise; getNewScreenTimeToDisplay(): Promise; + + getDataFromUri(uri: string): Promise; } const EOL = utf8ToBytes('\n'); @@ -702,6 +704,19 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.getNewScreenTimeToDisplay(); }, + async getDataFromUri(uri: string): Promise { + 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. diff --git a/packages/core/test/feedback/FeedbackForm.test.tsx b/packages/core/test/feedback/FeedbackForm.test.tsx index 33cf9d5811..92ed48fe4a 100644 --- a/packages/core/test/feedback/FeedbackForm.test.tsx +++ b/packages/core/test/feedback/FeedbackForm.test.tsx @@ -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(); @@ -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(); fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel)); @@ -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 = { + launchImageLibraryAsync: mockLaunchImageLibrary, + }; + + const { getByText } = render(); + + 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 = { + launchImageLibrary: mockLaunchImageLibrary, + }; + + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel)); + + await waitFor(() => { + expect(mockLaunchImageLibrary).toHaveBeenCalled(); + }); + }); + it('calls onFormClose when the cancel button is pressed', () => { const { getByText } = render(); diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 82b2b9194c..fe6a611394 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -59,6 +59,7 @@ const NATIVE: MockInterface = { crashedLastRun: jest.fn(), getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42), + getDataFromUri: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); diff --git a/samples/expo/app.json b/samples/expo/app.json index 184b88ec9d..0a58f60564 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -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", { diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index 952f21ca44..6c8d39a0c5 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -57,6 +57,12 @@ export default function TabOneScreen() { Sentry.nativeCrash(); }} /> +