diff --git a/__mocks__/@react-native-documents/picker.ts b/__mocks__/@react-native-documents/picker.ts new file mode 100644 index 000000000000..37e9303b64a7 --- /dev/null +++ b/__mocks__/@react-native-documents/picker.ts @@ -0,0 +1,24 @@ +import type {FileToCopy} from '@react-native-documents/picker'; + +const keepLocalCopy = jest.fn(); +const pick = jest.fn(); +const types = Object.freeze({ + allFiles: 'public.item', + audio: 'public.audio', + csv: 'public.comma-separated-values-text', + doc: 'com.microsoft.word.doc', + docx: 'org.openxmlformats.wordprocessingml.document', + images: 'public.image', + json: 'public.json', + pdf: 'com.adobe.pdf', + plainText: 'public.plain-text', + ppt: 'com.microsoft.powerpoint.ppt', + pptx: 'org.openxmlformats.presentationml.presentation', + video: 'public.movie', + xls: 'com.microsoft.excel.xls', + xlsx: 'org.openxmlformats.spreadsheetml.sheet', + zip: 'public.zip-archive', +}); + +export type {FileToCopy}; +export {keepLocalCopy, pick, types}; diff --git a/__mocks__/react-native-document-picker.ts b/__mocks__/react-native-document-picker.ts deleted file mode 100644 index 524e701f88fc..000000000000 --- a/__mocks__/react-native-document-picker.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {pick, pickDirectory, releaseSecureAccess, types} from 'react-native-document-picker'; - -type ReactNativeDocumentPickerMock = { - pick: typeof pick; - releaseSecureAccess: typeof releaseSecureAccess; - pickDirectory: typeof pickDirectory; - types: typeof types; -}; - -const reactNativeDocumentPickerMock: ReactNativeDocumentPickerMock = { - pick: jest.fn(), - releaseSecureAccess: jest.fn(), - pickDirectory: jest.fn(), - types: Object.freeze({ - allFiles: 'public.item', - audio: 'public.audio', - csv: 'public.comma-separated-values-text', - doc: 'com.microsoft.word.doc', - docx: 'org.openxmlformats.wordprocessingml.document', - images: 'public.image', - json: 'public.json', - pdf: 'com.adobe.pdf', - plainText: 'public.plain-text', - ppt: 'com.microsoft.powerpoint.ppt', - pptx: 'org.openxmlformats.presentationml.presentation', - video: 'public.movie', - xls: 'com.microsoft.excel.xls', - xlsx: 'org.openxmlformats.spreadsheetml.sheet', - zip: 'public.zip-archive', - }), -}; - -export default reactNativeDocumentPickerMock; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index de1d6addb24d..65d343ee5124 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1714,7 +1714,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-document-picker (9.3.1): + - react-native-document-picker (10.1.1): - DoubleConversion - glog - hermes-engine @@ -2991,7 +2991,7 @@ DEPENDENCIES: - react-native-blob-util (from `../node_modules/react-native-blob-util`) - "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)" - react-native-config (from `../node_modules/react-native-config`) - - react-native-document-picker (from `../node_modules/react-native-document-picker`) + - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" - "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-key-command (from `../node_modules/react-native-key-command`) @@ -3227,7 +3227,7 @@ EXTERNAL SOURCES: react-native-config: :path: "../node_modules/react-native-config" react-native-document-picker: - :path: "../node_modules/react-native-document-picker" + :path: "../node_modules/@react-native-documents/picker" react-native-geolocation: :path: "../node_modules/@react-native-community/geolocation" react-native-image-picker: @@ -3469,7 +3469,7 @@ SPEC CHECKSUMS: react-native-blob-util: d65692a2acce17b7a836805114828142a3634033 react-native-cameraroll: 78710a5d35b0c6f41899a193e714564d8fd648b0 react-native-config: 5b9e180ca7beb5748ba473b257bb9a7d10e2a957 - react-native-document-picker: da64a39fd71a84a9d3f7f58c8b0623c393e9991c + react-native-document-picker: 5cb1c6615796389f4f1b7fe2e4f103e38e4d6398 react-native-geolocation: cd91e4fd914de585933c836fd71f6bdb3c97a410 react-native-image-picker: 9e419813377d42566b0168121ab9483614488db5 react-native-key-command: 96c9dfb09bc89ecd1d58348c0d3ed84d0255648b diff --git a/package-lock.json b/package-lock.json index 669cf937f4bd..aa8f3a451941 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@react-native-clipboard/clipboard": "^1.15.0", "@react-native-community/geolocation": "3.3.0", "@react-native-community/netinfo": "11.2.1", + "@react-native-documents/picker": "^10.1.1", "@react-native-firebase/analytics": "^12.3.0", "@react-native-firebase/app": "^12.3.0", "@react-native-firebase/crashlytics": "^12.3.0", @@ -92,7 +93,6 @@ "react-native-collapsible": "^1.6.2", "react-native-config": "1.5.3", "react-native-device-info": "10.3.1", - "react-native-document-picker": "^9.3.1", "react-native-draggable-flatlist": "^4.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.22.0", @@ -9539,6 +9539,16 @@ "react-native": ">=0.59" } }, + "node_modules/@react-native-documents/picker": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@react-native-documents/picker/-/picker-10.1.1.tgz", + "integrity": "sha512-RijOCQmesOPe4ahtwOag6k6GLhLHf34sMQtAJ4KjLk7ab0kG5Chvcsvd5zixWC4d0tuBnVAtMUOzvn0bp+UtAg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native-firebase/analytics": { "version": "12.9.3", "license": "Apache-2.0", @@ -32830,24 +32840,6 @@ "react-native": "*" } }, - "node_modules/react-native-document-picker": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-9.3.1.tgz", - "integrity": "sha512-Vcofv9wfB0j67zawFjfq9WQPMMzXxOZL9kBmvWDpjVuEcVK73ndRmlXHlkeFl5ZHVsv4Zb6oZYhqm9u5omJOPA==", - "dependencies": { - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-windows": "*" - }, - "peerDependenciesMeta": { - "react-native-windows": { - "optional": true - } - } - }, "node_modules/react-native-draggable-flatlist": { "version": "4.0.1", "license": "MIT", diff --git a/package.json b/package.json index b246fa68a61b..16e2afcb5267 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "@react-native-clipboard/clipboard": "^1.15.0", "@react-native-community/geolocation": "3.3.0", "@react-native-community/netinfo": "11.2.1", + "@react-native-documents/picker": "^10.1.1", "@react-native-firebase/analytics": "^12.3.0", "@react-native-firebase/app": "^12.3.0", "@react-native-firebase/crashlytics": "^12.3.0", @@ -159,7 +160,6 @@ "react-native-collapsible": "^1.6.2", "react-native-config": "1.5.3", "react-native-device-info": "10.3.1", - "react-native-document-picker": "^9.3.1", "react-native-draggable-flatlist": "^4.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.22.0", diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 995442ea9cf2..ada6b2c110f7 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -1,10 +1,10 @@ +import type {FileToCopy} from '@react-native-documents/picker'; +import {keepLocalCopy, pick, types} from '@react-native-documents/picker'; import {Str} from 'expensify-common'; -import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'; +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import React, {useCallback, useMemo, useRef, useState} from 'react'; import {Alert, View} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; -import RNDocumentPicker from 'react-native-document-picker'; -import type {DocumentPickerOptions, DocumentPickerResponse} from 'react-native-document-picker'; import {launchImageLibrary} from 'react-native-image-picker'; import type {Asset, Callback, CameraOptions, ImageLibraryOptions, ImagePickerResponse} from 'react-native-image-picker'; import ImageSize from 'react-native-image-size'; @@ -19,20 +19,27 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as FileUtils from '@libs/fileDownload/FileUtils'; +import {cleanFileName, showCameraPermissionsAlert, verifyFileFormat} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type IconAsset from '@src/types/utils/IconAsset'; import launchCamera from './launchCamera/launchCamera'; import type AttachmentPickerProps from './types'; +type LocalCopy = { + name: string | null; + uri: string; + size: number | null; + type: string | null; +}; + type Item = { /** The icon associated with the item. */ icon: IconAsset; /** The key in the translations file to use for the title */ textTranslationKey: TranslationPaths; /** Function to call when the user clicks the item */ - pickAttachment: () => Promise; + pickAttachment: () => Promise; }; /** @@ -56,28 +63,6 @@ const getImagePickerOptions = (type: string, fileLimit: number): CameraOptions | }; }; -/** - * Return documentPickerOptions based on the type - * @param {String} type - * @param {Number} fileLimit - * @returns {Object} - */ - -const getDocumentPickerOptions = (type: string, fileLimit: number): DocumentPickerOptions => { - if (type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE) { - return { - type: [RNDocumentPicker.types.images], - copyTo: 'cachesDirectory', - allowMultiSelection: fileLimit !== 1, - }; - } - return { - type: [RNDocumentPicker.types.allFiles], - copyTo: 'cachesDirectory', - allowMultiSelection: fileLimit !== 1, - }; -}; - /** * The data returned from `show` is different on web and mobile, so use this function to ensure the data we * send to the xhr will be handled properly. @@ -85,7 +70,7 @@ const getDocumentPickerOptions = (type: string, fileLimit: number): DocumentPick const getDataForUpload = (fileData: FileResponse): Promise => { const fileName = fileData.name || 'chat_attachment'; const fileResult: FileObject = { - name: FileUtils.cleanFileName(fileName), + name: cleanFileName(fileName), type: fileData.type, width: fileData.width, height: fileData.height, @@ -158,7 +143,7 @@ function AttachmentPicker({ if (response.errorCode) { switch (response.errorCode) { case 'permission': - FileUtils.showCameraPermissionsAlert(); + showCameraPermissionsAlert(); return resolve(); default: showGeneralAlert(); @@ -176,11 +161,13 @@ function AttachmentPicker({ } if (targetAsset?.type?.startsWith('image')) { - FileUtils.verifyFileFormat({fileUri: targetAssetUri, formatSignatures: CONST.HEIC_SIGNATURES}) + verifyFileFormat({fileUri: targetAssetUri, formatSignatures: CONST.HEIC_SIGNATURES}) .then((isHEIC) => { // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature if (isHEIC && targetAssetUri) { - manipulateAsync(targetAssetUri, [], {format: SaveFormat.JPEG}) + ImageManipulator.manipulate(targetAssetUri) + .renderAsync() + .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG})) .then((manipResult) => { const uri = manipResult.uri; const convertedAsset = { @@ -211,21 +198,38 @@ function AttachmentPicker({ ); /** * Launch the DocumentPicker. Results are in the same format as ImagePicker - * - * @returns {Promise} */ - const showDocumentPicker = useCallback( - (): Promise => - RNDocumentPicker.pick(getDocumentPickerOptions(type, fileLimit)).catch((error: Error) => { - if (RNDocumentPicker.isCancel(error)) { - return; - } + // eslint-disable-next-line @lwc/lwc/no-async-await + const showDocumentPicker = useCallback(async (): Promise => { + const pickedFiles = await pick({ + type: [type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? types.images : types.allFiles], + allowMultiSelection: fileLimit !== 1, + }); - showGeneralAlert(error.message); - throw error; - }), - [fileLimit, showGeneralAlert, type], - ); + const localCopies = await keepLocalCopy({ + files: pickedFiles.map((file) => { + return { + uri: file.uri, + fileName: file.name ?? '', + }; + }) as [FileToCopy, ...FileToCopy[]], + destination: 'cachesDirectory', + }); + + return pickedFiles.map((file, index) => { + const localCopy = localCopies[index]; + if (localCopy.status !== 'success') { + throw new Error("Couldn't create local file copy"); + } + + return { + name: file.name, + uri: localCopy.localUri, + size: file.size, + type: file.type, + }; + }); + }, [fileLimit, type]); const menuItemData: Item[] = useMemo(() => { const data: Item[] = [ @@ -309,7 +313,7 @@ function AttachmentPicker({ * sends the selected attachment to the caller (parent component) */ const pickAttachment = useCallback( - (attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise | undefined => { + (attachments: Asset[] | LocalCopy[] | void = []): Promise | undefined => { if (!attachments || attachments.length === 0) { onCanceled.current(); return Promise.resolve([]); @@ -323,7 +327,7 @@ function AttachmentPicker({ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || ''; - const fileDataUri = ('fileCopyUri' in fileData && fileData.fileCopyUri) || ('uri' in fileData && fileData.uri) || ''; + const fileDataUri = ('uri' in fileData && fileData.uri) || ''; const fileDataObject: FileResponse = { name: fileDataName ?? '', @@ -397,6 +401,14 @@ function AttachmentPicker({ onModalHide.current = () => { setTimeout(() => { item.pickAttachment() + .catch((error: Error) => { + if (JSON.stringify(error).includes('OPERATION_CANCELED')) { + return; + } + + showGeneralAlert(error.message); + throw error; + }) .then((result) => pickAttachment(result)) .catch(console.error) .finally(() => { @@ -407,7 +419,7 @@ function AttachmentPicker({ }; close(); }, - [pickAttachment, onOpenPicker], + [onOpenPicker, pickAttachment, showGeneralAlert], ); useKeyboardShortcut( diff --git a/src/components/FilePicker/index.native.tsx b/src/components/FilePicker/index.native.tsx index 4b381713e44b..9e7b27e10e2d 100644 --- a/src/components/FilePicker/index.native.tsx +++ b/src/components/FilePicker/index.native.tsx @@ -1,34 +1,27 @@ +import {keepLocalCopy, pick, types} from '@react-native-documents/picker'; import React, {useCallback, useRef} from 'react'; import {Alert} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; -import RNDocumentPicker from 'react-native-document-picker'; -import type {DocumentPickerResponse} from 'react-native-document-picker'; import type {FileObject} from '@components/AttachmentModal'; import useLocalize from '@hooks/useLocalize'; -import * as FileUtils from '@libs/fileDownload/FileUtils'; +import {cleanFileName} from '@libs/fileDownload/FileUtils'; import type FilePickerProps from './types'; -/** - * Utility function to get the file name from DocumentPickerResponse - */ -const getFileDataName = (fileData: DocumentPickerResponse): string => { - if ('fileName' in fileData) { - return fileData.fileName as string; - } - if ('name' in fileData && fileData.name) { - return fileData.name; - } - return ''; +type LocalCopy = { + name: string | null; + uri: string; + size: number | null; + type: string | null; }; /** * The data returned from `show` is different on web and mobile, * use this function to ensure the data will be handled properly. */ -const getDataForUpload = (fileData: DocumentPickerResponse): Promise => { +const getDataForUpload = (fileData: LocalCopy): Promise => { const fileName = fileData.name ?? 'spreadsheet'; const fileResult: FileObject = { - name: FileUtils.cleanFileName(fileName), + name: cleanFileName(fileName), type: fileData.type ?? undefined, uri: fileData.uri, size: fileData.size, @@ -60,35 +53,17 @@ function FilePicker({children}: FilePickerProps) { [translate], ); - /** - * Launches the DocumentPicker - * - * @returns {Promise} - */ - const showFilePicker = useCallback( - (): Promise => - RNDocumentPicker.pick({ - type: [RNDocumentPicker.types.allFiles], - copyTo: 'cachesDirectory', - }).catch((error: Error) => { - if (RNDocumentPicker.isCancel(error)) { - onCanceled.current(); - return; - } - - showGeneralAlert(error.message); - throw error; - }), - [showGeneralAlert], - ); - /** * Validates and completes file selection * * @param fileData The file data received from the picker */ const validateAndCompleteFileSelection = useCallback( - (fileData: DocumentPickerResponse) => { + (fileData: LocalCopy | void) => { + if (!fileData) { + onCanceled.current(); + return; + } return getDataForUpload(fileData) .then((result) => { completeFileSelection.current(result); @@ -106,34 +81,29 @@ function FilePicker({children}: FilePickerProps) { * * @param files The array of DocumentPickerResponse */ - const pickFile = useCallback( - (files: DocumentPickerResponse[] | void = []): Promise | undefined => { - if (!files || files.length === 0) { - onCanceled.current(); - return Promise.resolve(); - } - const fileData = files.at(0); + // eslint-disable-next-line @lwc/lwc/no-async-await + const pickFile = async (): Promise => { + const [file] = await pick({ + type: [types.allFiles], + }); - if (!fileData) { - onCanceled.current(); - return Promise.resolve(); - } + const [localCopy] = await keepLocalCopy({ + files: [ + { + uri: file.uri, + fileName: file.name ?? 'spreadsheet', + }, + ], + destination: 'cachesDirectory', + }); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const fileDataUri = fileData.fileCopyUri ?? ''; - const fileDataName = getFileDataName(fileData); - const fileDataObject: DocumentPickerResponse = { - name: fileDataName, - uri: fileDataUri, - type: fileData.type ?? '', - fileCopyUri: fileDataUri, - size: fileData.size ?? 0, - }; - /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ - validateAndCompleteFileSelection(fileDataObject); - }, - [validateAndCompleteFileSelection], - ); + return { + name: cleanFileName(file.name ?? 'spreadsheet'), + type: file.type, + uri: localCopy.sourceUri, + size: file.size, + }; + }; /** * Opens the file picker @@ -141,10 +111,22 @@ function FilePicker({children}: FilePickerProps) { * @param onPickedHandler A callback that will be called with the selected file * @param onCanceledHandler A callback that will be called if the file is canceled */ + // eslint-disable-next-line @lwc/lwc/no-async-await const open = (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => { completeFileSelection.current = onPickedHandler; onCanceled.current = onCanceledHandler; - showFilePicker().then(pickFile).catch(console.error); + pickFile() + .catch((error: Error) => { + if (JSON.stringify(error).includes('OPERATION_CANCELED')) { + onCanceled.current(); + return Promise.resolve(); + } + + showGeneralAlert(error.message); + throw error; + }) + .then(validateAndCompleteFileSelection) + .catch(console.error); }; /**