From fcbf6b58c4e00cd2032bb03e6fac744e5389adfb Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 28 Mar 2025 17:56:11 +0100 Subject: [PATCH 01/10] migrate FilePicker wip --- __mocks__/react-native-document-picker.ts | 2 +- package-lock.json | 30 ++--- package.json | 2 +- .../AttachmentPicker/index.native.tsx | 4 +- src/components/FilePicker/index.native.tsx | 126 ++++++------------ 5 files changed, 53 insertions(+), 111 deletions(-) diff --git a/__mocks__/react-native-document-picker.ts b/__mocks__/react-native-document-picker.ts index 524e701f88fc..eaf655d0bb20 100644 --- a/__mocks__/react-native-document-picker.ts +++ b/__mocks__/react-native-document-picker.ts @@ -1,4 +1,4 @@ -import type {pick, pickDirectory, releaseSecureAccess, types} from 'react-native-document-picker'; +import type {pick, pickDirectory, releaseSecureAccess, types} from '@react-native-documents/picker'; type ReactNativeDocumentPickerMock = { pick: typeof pick; diff --git a/package-lock.json b/package-lock.json index 56884a12ef8d..23d39bd90349 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,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", @@ -90,7 +91,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", @@ -9621,6 +9621,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", @@ -33317,24 +33327,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 3c3ba250f604..c336a2f1bc24 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,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", @@ -157,7 +158,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 70966a05b918..ec7ffe4bf015 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -1,10 +1,10 @@ +import RNDocumentPicker from '@react-native-documents/picker'; +import type {DocumentPickerOptions, DocumentPickerResponse} from '@react-native-documents/picker'; import {Str} from 'expensify-common'; import {manipulateAsync, 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'; diff --git a/src/components/FilePicker/index.native.tsx b/src/components/FilePicker/index.native.tsx index 4b381713e44b..946a97de0724 100644 --- a/src/components/FilePicker/index.native.tsx +++ b/src/components/FilePicker/index.native.tsx @@ -1,46 +1,24 @@ +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 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 ''; -}; - /** * 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 fileName = fileData.name ?? 'spreadsheet'; - const fileResult: FileObject = { - name: FileUtils.cleanFileName(fileName), - type: fileData.type ?? undefined, - uri: fileData.uri, - size: fileData.size, - }; - - if (fileResult.size) { - return Promise.resolve(fileResult); +const getDataForUpload = (fileData: FileObject): Promise => { + if (fileData.size) { + return Promise.resolve(fileData); } - return RNFetchBlob.fs.stat(fileData.uri.replace('file://', '')).then((stats) => { - fileResult.size = stats.size; - return fileResult; + return RNFetchBlob.fs.stat(fileData.uri!.replace('file://', '')).then((stats) => { + fileData.size = stats.size; + return fileData; }); }; @@ -60,35 +38,13 @@ 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: FileObject) => { return getDataForUpload(fileData) .then((result) => { completeFileSelection.current(result); @@ -101,50 +57,44 @@ function FilePicker({children}: FilePickerProps) { [showGeneralAlert], ); - /** - * Handles the file picker result and sends the selected file to the caller - * - * @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); - - if (!fileData) { - onCanceled.current(); - return Promise.resolve(); - } - - // 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], - ); - /** * Opens the file picker * * @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 */ - const open = (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => { + // eslint-disable-next-line @lwc/lwc/no-async-await + const open = async (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => { completeFileSelection.current = onPickedHandler; onCanceled.current = onCanceledHandler; - showFilePicker().then(pickFile).catch(console.error); + + try { + const [file] = await pick({ + type: [types.allFiles], + }); + + const [localCopy] = await keepLocalCopy({ + files: [ + { + uri: file.uri, + fileName: file.name ?? 'spreadsheet', + }, + ], + destination: 'cachesDirectory', + }); + + const fileResult: FileObject = { + name: FileUtils.cleanFileName(file.name ?? 'spreadsheet'), + type: file.type ?? undefined, + uri: localCopy.sourceUri, + size: file.size, + }; + + validateAndCompleteFileSelection(fileResult); + } catch (error) { + showGeneralAlert(error.message); + throw error; + } }; /** From ac7c55c909eb7a9ff62c125cab6f09dedce8e672 Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 31 Mar 2025 14:00:56 +0200 Subject: [PATCH 02/10] migrate AttachmentPicker --- .../AttachmentPicker/index.native.tsx | 98 +++++++++++-------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index ec7ffe4bf015..c748ca5877b5 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -1,5 +1,5 @@ -import RNDocumentPicker from '@react-native-documents/picker'; -import type {DocumentPickerOptions, DocumentPickerResponse} from '@react-native-documents/picker'; +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 React, {useCallback, useMemo, useRef, useState} from 'react'; @@ -26,13 +26,20 @@ 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. @@ -209,21 +194,52 @@ 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 = async (): Promise => { + try { + const [pickedFile, ...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 [localCopy, ...localCopies] = await keepLocalCopy({ + files: [ + { + uri: pickedFile.uri, + fileName: pickedFile.name ?? '', + } as FileToCopy, + ...pickedFiles.map((file) => { + return { + uri: file.uri, + fileName: file.name ?? '', + } as FileToCopy; + }), + ], + destination: 'cachesDirectory', + }); + + return [ + { + name: pickedFile.name, + uri: localCopy.sourceUri, + size: pickedFile.size, + type: pickedFile.type, + }, + ...pickedFiles.map((file, index) => { + return { + name: file.name, + uri: localCopies.at(index)?.sourceUri ?? file.uri, + size: file.size, + type: file.type, + }; + }), + ]; + } catch (error) { + showGeneralAlert(error.message); + throw error; + } + }; const menuItemData: Item[] = useMemo(() => { const data: Item[] = [ @@ -306,7 +322,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([]); @@ -319,8 +335,8 @@ 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 fileDataName = ('name' in fileData && fileData.name) || ''; + const fileDataUri = ('uri' in fileData && fileData.uri) || ''; const fileDataObject: FileResponse = { name: fileDataName ?? '', From f6cff555ba4608f7714ff3254a1a84e44ba079c9 Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 31 Mar 2025 14:08:46 +0200 Subject: [PATCH 03/10] remove unnecessary changes from FilePicker --- src/components/FilePicker/index.native.tsx | 56 +++++++++++++++------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/components/FilePicker/index.native.tsx b/src/components/FilePicker/index.native.tsx index 946a97de0724..9182173ef4ab 100644 --- a/src/components/FilePicker/index.native.tsx +++ b/src/components/FilePicker/index.native.tsx @@ -7,18 +7,33 @@ import useLocalize from '@hooks/useLocalize'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import type FilePickerProps from './types'; +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: FileObject): Promise => { - if (fileData.size) { - return Promise.resolve(fileData); +const getDataForUpload = (fileData: LocalCopy): Promise => { + const fileName = fileData.name ?? 'spreadsheet'; + const fileResult: FileObject = { + name: FileUtils.cleanFileName(fileName), + type: fileData.type ?? undefined, + uri: fileData.uri, + size: fileData.size, + }; + + if (fileResult.size) { + return Promise.resolve(fileResult); } - return RNFetchBlob.fs.stat(fileData.uri!.replace('file://', '')).then((stats) => { - fileData.size = stats.size; - return fileData; + return RNFetchBlob.fs.stat(fileData.uri.replace('file://', '')).then((stats) => { + fileResult.size = stats.size; + return fileResult; }); }; @@ -44,7 +59,7 @@ function FilePicker({children}: FilePickerProps) { * @param fileData The file data received from the picker */ const validateAndCompleteFileSelection = useCallback( - (fileData: FileObject) => { + (fileData: LocalCopy) => { return getDataForUpload(fileData) .then((result) => { completeFileSelection.current(result); @@ -58,16 +73,12 @@ function FilePicker({children}: FilePickerProps) { ); /** - * Opens the file picker + * Handles the file picker result and sends the selected file to the caller * - * @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 + * @param files The array of DocumentPickerResponse */ // eslint-disable-next-line @lwc/lwc/no-async-await - const open = async (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => { - completeFileSelection.current = onPickedHandler; - onCanceled.current = onCanceledHandler; - + const pickFile = async () => { try { const [file] = await pick({ type: [types.allFiles], @@ -83,9 +94,9 @@ function FilePicker({children}: FilePickerProps) { destination: 'cachesDirectory', }); - const fileResult: FileObject = { + const fileResult: LocalCopy = { name: FileUtils.cleanFileName(file.name ?? 'spreadsheet'), - type: file.type ?? undefined, + type: file.type, uri: localCopy.sourceUri, size: file.size, }; @@ -97,6 +108,19 @@ function FilePicker({children}: FilePickerProps) { } }; + /** + * Opens the file picker + * + * @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; + pickFile(); + }; + /** * Call the `children` render prop with the interface defined in propTypes */ From 94236e46bcbb34dbc487b89e4eabb823ccebb25b Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 31 Mar 2025 15:46:32 +0200 Subject: [PATCH 04/10] fix types --- .../AttachmentPicker/index.native.tsx | 71 ++++++++----------- src/components/FilePicker/index.native.tsx | 68 ++++++++++-------- 2 files changed, 68 insertions(+), 71 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index c748ca5877b5..8aeb6b001126 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -196,49 +196,30 @@ function AttachmentPicker({ * Launch the DocumentPicker. Results are in the same format as ImagePicker */ // eslint-disable-next-line @lwc/lwc/no-async-await - const showDocumentPicker = async (): Promise => { - try { - const [pickedFile, ...pickedFiles] = await pick({ - type: [type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? types.images : types.allFiles], - allowMultiSelection: fileLimit !== 1, - }); + const showDocumentPicker = async (): Promise => { + const pickedFiles = await pick({ + type: [type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? types.images : types.allFiles], + allowMultiSelection: fileLimit !== 1, + }); - const [localCopy, ...localCopies] = await keepLocalCopy({ - files: [ - { - uri: pickedFile.uri, - fileName: pickedFile.name ?? '', - } as FileToCopy, - ...pickedFiles.map((file) => { - return { - uri: file.uri, - fileName: file.name ?? '', - } as FileToCopy; - }), - ], - destination: 'cachesDirectory', - }); + const localCopies = await keepLocalCopy({ + files: pickedFiles.map((file) => { + return { + uri: file.uri, + fileName: file.name ?? '', + }; + }) as [FileToCopy, ...FileToCopy[]], + destination: 'cachesDirectory', + }); - return [ - { - name: pickedFile.name, - uri: localCopy.sourceUri, - size: pickedFile.size, - type: pickedFile.type, - }, - ...pickedFiles.map((file, index) => { - return { - name: file.name, - uri: localCopies.at(index)?.sourceUri ?? file.uri, - size: file.size, - type: file.type, - }; - }), - ]; - } catch (error) { - showGeneralAlert(error.message); - throw error; - } + return pickedFiles.map((file, index) => { + return { + name: file.name, + uri: localCopies.at(index)?.sourceUri ?? file.uri, + size: file.size, + type: file.type, + }; + }); }; const menuItemData: Item[] = useMemo(() => { @@ -409,6 +390,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(() => delete onModalHide.current); diff --git a/src/components/FilePicker/index.native.tsx b/src/components/FilePicker/index.native.tsx index 9182173ef4ab..867c90770de4 100644 --- a/src/components/FilePicker/index.native.tsx +++ b/src/components/FilePicker/index.native.tsx @@ -59,7 +59,11 @@ function FilePicker({children}: FilePickerProps) { * @param fileData The file data received from the picker */ const validateAndCompleteFileSelection = useCallback( - (fileData: LocalCopy) => { + (fileData: LocalCopy | void) => { + if (!fileData) { + onCanceled.current(); + return; + } return getDataForUpload(fileData) .then((result) => { completeFileSelection.current(result); @@ -78,34 +82,27 @@ function FilePicker({children}: FilePickerProps) { * @param files The array of DocumentPickerResponse */ // eslint-disable-next-line @lwc/lwc/no-async-await - const pickFile = async () => { - try { - const [file] = await pick({ - type: [types.allFiles], - }); - - const [localCopy] = await keepLocalCopy({ - files: [ - { - uri: file.uri, - fileName: file.name ?? 'spreadsheet', - }, - ], - destination: 'cachesDirectory', - }); - - const fileResult: LocalCopy = { - name: FileUtils.cleanFileName(file.name ?? 'spreadsheet'), - type: file.type, - uri: localCopy.sourceUri, - size: file.size, - }; - - validateAndCompleteFileSelection(fileResult); - } catch (error) { - showGeneralAlert(error.message); - throw error; - } + const pickFile = async (): Promise => { + const [file] = await pick({ + type: [types.allFiles], + }); + + const [localCopy] = await keepLocalCopy({ + files: [ + { + uri: file.uri, + fileName: file.name ?? 'spreadsheet', + }, + ], + destination: 'cachesDirectory', + }); + + return { + name: FileUtils.cleanFileName(file.name ?? 'spreadsheet'), + type: file.type, + uri: localCopy.sourceUri, + size: file.size, + }; }; /** @@ -118,7 +115,18 @@ function FilePicker({children}: FilePickerProps) { const open = (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => { completeFileSelection.current = onPickedHandler; onCanceled.current = onCanceledHandler; - pickFile(); + 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); }; /** From eb8d5c9f50c0597311dbb7a4e61dda5e33a75c45 Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 31 Mar 2025 16:34:42 +0200 Subject: [PATCH 05/10] fix tests --- __mocks__/@react-native-documents/picker.ts | 24 +++++++++++++++ __mocks__/react-native-document-picker.ts | 33 --------------------- 2 files changed, 24 insertions(+), 33 deletions(-) create mode 100644 __mocks__/@react-native-documents/picker.ts delete mode 100644 __mocks__/react-native-document-picker.ts 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 eaf655d0bb20..000000000000 --- a/__mocks__/react-native-document-picker.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {pick, pickDirectory, releaseSecureAccess, types} from '@react-native-documents/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; From 8a1f559407587514e584f0c6ce865255b4efabdc Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 31 Mar 2025 16:48:27 +0200 Subject: [PATCH 06/10] fix lint & bump Podfile.lock --- ios/Podfile.lock | 8 ++++---- src/components/AttachmentPicker/index.native.tsx | 14 +++++++------- src/components/FilePicker/index.native.tsx | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 48fe31431651..07330535b0dc 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1677,7 +1677,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 @@ -2930,7 +2930,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`) @@ -3163,7 +3163,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: @@ -3400,7 +3400,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/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 8aeb6b001126..2f02500bbe05 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -19,7 +19,7 @@ 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'; @@ -70,7 +70,7 @@ const getImagePickerOptions = (type: string, fileLimit: number): CameraOptions | 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, @@ -141,7 +141,7 @@ function AttachmentPicker({ if (response.errorCode) { switch (response.errorCode) { case 'permission': - FileUtils.showCameraPermissionsAlert(); + showCameraPermissionsAlert(); return resolve(); default: showGeneralAlert(); @@ -159,7 +159,7 @@ 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) { @@ -196,7 +196,7 @@ function AttachmentPicker({ * Launch the DocumentPicker. Results are in the same format as ImagePicker */ // eslint-disable-next-line @lwc/lwc/no-async-await - const showDocumentPicker = async (): Promise => { + const showDocumentPicker = useCallback(async (): Promise => { const pickedFiles = await pick({ type: [type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? types.images : types.allFiles], allowMultiSelection: fileLimit !== 1, @@ -220,7 +220,7 @@ function AttachmentPicker({ type: file.type, }; }); - }; + }, [fileLimit, type]); const menuItemData: Item[] = useMemo(() => { const data: Item[] = [ @@ -405,7 +405,7 @@ function AttachmentPicker({ }; close(); }, - [pickAttachment], + [pickAttachment, showGeneralAlert], ); useKeyboardShortcut( diff --git a/src/components/FilePicker/index.native.tsx b/src/components/FilePicker/index.native.tsx index 867c90770de4..9e7b27e10e2d 100644 --- a/src/components/FilePicker/index.native.tsx +++ b/src/components/FilePicker/index.native.tsx @@ -4,7 +4,7 @@ import {Alert} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; 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'; type LocalCopy = { @@ -21,7 +21,7 @@ type LocalCopy = { 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, @@ -98,7 +98,7 @@ function FilePicker({children}: FilePickerProps) { }); return { - name: FileUtils.cleanFileName(file.name ?? 'spreadsheet'), + name: cleanFileName(file.name ?? 'spreadsheet'), type: file.type, uri: localCopy.sourceUri, size: file.size, From 9cc1d784d6caf221216f851246fc1d444eb5eb05 Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 31 Mar 2025 17:31:46 +0200 Subject: [PATCH 07/10] migrate manipulateAsync usage --- src/components/AttachmentPicker/index.native.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 2f02500bbe05..d90e06b5fd72 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -1,7 +1,7 @@ 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'; @@ -163,7 +163,9 @@ function AttachmentPicker({ .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 = { From b4c57065c2ba9089d5b238be51d499d879e816d3 Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 14 Apr 2025 15:55:47 +0200 Subject: [PATCH 08/10] fix pdf preview --- src/components/AttachmentPicker/index.native.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 99b880d1015a..84cc10c96344 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -225,9 +225,14 @@ function AttachmentPicker({ }); 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: localCopies.at(index)?.sourceUri ?? file.uri, + uri: localCopy.localUri, size: file.size, type: file.type, }; From 7affcb2aee8294d89f8125d48cac768da96b4da2 Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 15 Apr 2025 13:20:57 +0200 Subject: [PATCH 09/10] fix pick image from gallery issue --- src/components/AttachmentPicker/index.native.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 84cc10c96344..e7f28246dd25 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -208,7 +208,7 @@ function AttachmentPicker({ * Launch the DocumentPicker. Results are in the same format as ImagePicker */ // eslint-disable-next-line @lwc/lwc/no-async-await - const showDocumentPicker = useCallback(async (): Promise => { + const showDocumentPicker = useCallback(async (): Promise => { const pickedFiles = await pick({ type: [type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? types.images : types.allFiles], allowMultiSelection: fileLimit !== 1, @@ -334,7 +334,7 @@ function AttachmentPicker({ } /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ - const fileDataName = ('name' in fileData && fileData.name) || ''; + const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || ''; const fileDataUri = ('uri' in fileData && fileData.uri) || ''; const fileDataObject: FileResponse = { From 6fb7a64b445af61b4cfecb018aba07835c0113c2 Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 15 Apr 2025 18:40:46 +0200 Subject: [PATCH 10/10] migrate to ImageManipulator --- src/components/AttachmentPicker/index.native.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index e6133fd7bb23..ada6b2c110f7 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -1,7 +1,7 @@ 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'; @@ -19,7 +19,7 @@ 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'; @@ -70,7 +70,7 @@ const getImagePickerOptions = (type: string, fileLimit: number): CameraOptions | 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, @@ -143,7 +143,7 @@ function AttachmentPicker({ if (response.errorCode) { switch (response.errorCode) { case 'permission': - FileUtils.showCameraPermissionsAlert(); + showCameraPermissionsAlert(); return resolve(); default: showGeneralAlert(); @@ -161,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 = {