Skip to content
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

[Search] Export CSV on native devices #45593

Merged
8 changes: 0 additions & 8 deletions src/components/Search/SearchActionOptionsUtils.desktop.tsx

This file was deleted.

8 changes: 0 additions & 8 deletions src/components/Search/SearchActionOptionsUtils.native.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function ExpenseItemHeaderNarrow({
action={action}
goToItem={onButtonPress}
isLargeScreenWidth={false}
isSelected={isSelected}
/>
</View>
</View>
Expand Down
73 changes: 73 additions & 0 deletions src/libs/fileDownload/DownloadUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as ApiUtils from '@libs/ApiUtils';
import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
import * as Link from '@userActions/Link';
import CONST from '@src/CONST';
import * as FileUtils from './FileUtils';
import type {FileDownload} from './types';

const createDownloadLink = (href: string, fileName: string) => {
// creating anchor tag to initiate download
const link = document.createElement('a');
// adding href to anchor
link.href = href;
link.style.display = 'none';

// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and since fileName can be an empty string we want to default to `FileUtils.getFileName(url)`
link.download = fileName;

// Append to html link element page
document.body.appendChild(link);

// Start download
link.click();

// Clean up and remove the link
URL.revokeObjectURL(link.href);
link.parentNode?.removeChild(link);
};

/**
* The function downloads an attachment on web/desktop platforms.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const fetchFileDownload: FileDownload = (url, fileName, successMessage = '', shouldOpenExternalLink = false, formData = undefined, requestType = 'get', onDownloadFailed?: () => void) => {
const resolvedUrl = tryResolveUrlFromApiRoot(url);

const isApiUrl = resolvedUrl.startsWith(ApiUtils.getApiRoot());
const isAttachmentUrl = CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix));
const isSageUrl = url === CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT;
if (
// We have two file download cases that we should allow: 1. downloading attachments 2. downloading Expensify package for Sage Intacct
shouldOpenExternalLink ||
(!isApiUrl && !isAttachmentUrl && !isSageUrl)
) {
filip-solecki marked this conversation as resolved.
Show resolved Hide resolved
// Different origin URLs might pose a CORS issue during direct downloads.
// Opening in a new tab avoids this limitation, letting the browser handle the download.
Link.openExternalLink(url);
return Promise.resolve();
}

const fetchOptions: RequestInit = {
method: requestType,
body: formData,
};

return fetch(url, fetchOptions)
.then((response) => response.blob())
.then((blob) => {
// Create blob link to download
const href = URL.createObjectURL(new Blob([blob]));
const completeFileName = FileUtils.appendTimeToFileName(fileName ?? FileUtils.getFileName(url));
createDownloadLink(href, completeFileName);
})
.catch(() => {
if (onDownloadFailed) {
onDownloadFailed();
} else {
// file could not be downloaded, open sourceURL in new tab
Link.openExternalLink(url);
}
});
};

export default fetchFileDownload;
49 changes: 48 additions & 1 deletion src/libs/fileDownload/index.android.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {PermissionsAndroid, Platform} from 'react-native';
import type {FetchBlobResponse} from 'react-native-blob-util';
import RNFetchBlob from 'react-native-blob-util';
import RNFS from 'react-native-fs';
import CONST from '@src/CONST';
import * as FileUtils from './FileUtils';
import type {FileDownload} from './types';

Expand Down Expand Up @@ -94,14 +96,59 @@ function handleDownload(url: string, fileName?: string, successMessage?: string)
});
}

const postDownloadFile = (url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void): Promise<void> => {
const fetchOptions: RequestInit = {
method: 'POST',
body: formData,
};

return fetch(url, fetchOptions)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to download file');
}
return response.text();
})
.then((fileData) => {
const finalFileName = FileUtils.appendTimeToFileName(fileName ?? 'Expensify');
const downloadPath = `${RNFS.DownloadDirectoryPath}/Expensify/${finalFileName}`;

return RNFS.writeFile(downloadPath, fileData, 'utf8').then(() => downloadPath);
})
.then((downloadPath) =>
RNFetchBlob.MediaCollection.copyToMediaStore(
{
name: FileUtils.getFileName(downloadPath),
parentFolder: 'Expensify',
mimeType: null,
},
'Download',
downloadPath,
).then(() => downloadPath),
)
.then((downloadPath) => {
RNFetchBlob.fs.unlink(downloadPath);
FileUtils.showSuccessAlert();
})
.catch(() => {
if (!onDownloadFailed) {
FileUtils.showGeneralErrorAlert();
}
onDownloadFailed?.();
});
};

/**
* Checks permission and downloads the file for Android
*/
const fileDownload: FileDownload = (url, fileName, successMessage) =>
const fileDownload: FileDownload = (url, fileName, successMessage, _, formData, requestType, onDownloadFailed) =>
new Promise((resolve) => {
hasAndroidPermission()
.then((hasPermission) => {
if (hasPermission) {
if (requestType === CONST.NETWORK.METHOD.POST) {
return postDownloadFile(url, fileName, formData, onDownloadFailed);
}
return handleDownload(url, fileName, successMessage);
}
FileUtils.showPermissionErrorAlert();
Expand Down
8 changes: 7 additions & 1 deletion src/libs/fileDownload/index.desktop.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import ELECTRON_EVENTS from '@desktop/ELECTRON_EVENTS';
import type Options from '@desktop/electronDownloadManagerType';
import CONST from '@src/CONST';
import fetchFileDownload from './DownloadUtils';
import type {FileDownload} from './types';

/**
* The function downloads an attachment on desktop platforms.
*/
const fileDownload: FileDownload = (url, fileName) => {
const fileDownload: FileDownload = (url, fileName, successMessage, shouldOpenExternalLink, formData, requestType) => {
if (requestType === CONST.NETWORK.METHOD.POST) {
window.electron.send(ELECTRON_EVENTS.DOWNLOAD);
return fetchFileDownload(url, fileName, successMessage, shouldOpenExternalLink, formData, requestType);
}

const options: Options = {
filename: fileName,
saveAs: true,
Expand Down
41 changes: 40 additions & 1 deletion src/libs/fileDownload/index.ios.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {CameraRoll} from '@react-native-camera-roll/camera-roll';
import type {PhotoIdentifier} from '@react-native-camera-roll/camera-roll';
import RNFetchBlob from 'react-native-blob-util';
import RNFS from 'react-native-fs';
import CONST from '@src/CONST';
import * as FileUtils from './FileUtils';
import type {FileDownload} from './types';
Expand All @@ -26,6 +27,39 @@ function downloadFile(fileUrl: string, fileName: string) {
}).fetch('GET', fileUrl);
}

const postDownloadFile = (url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void) => {
const fetchOptions: RequestInit = {
method: 'POST',
body: formData,
};

return fetch(url, fetchOptions)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to download file');
}
return response.text();
})
.then((fileData) => {
const finalFileName = FileUtils.appendTimeToFileName(fileName ?? 'Expensify');
const expensifyDir = `${RNFS.DocumentDirectoryPath}/Expensify`;

return RNFS.mkdir(expensifyDir).then(() => {
const localPath = `${expensifyDir}/${finalFileName}`;
return RNFS.writeFile(localPath, fileData, 'utf8').then(() => localPath);
});
})
.then(() => {
FileUtils.showSuccessAlert();
})
.catch(() => {
if (!onDownloadFailed) {
FileUtils.showGeneralErrorAlert();
}
onDownloadFailed?.();
});
};

/**
* Download the image to photo lib in iOS
*/
Expand Down Expand Up @@ -67,7 +101,7 @@ function downloadVideo(fileUrl: string, fileName: string): Promise<PhotoIdentifi
/**
* Download the file based on type(image, video, other file types)for iOS
*/
const fileDownload: FileDownload = (fileUrl, fileName, successMessage) =>
const fileDownload: FileDownload = (fileUrl, fileName, successMessage, _, formData, requestType, onDownloadFailed) =>
new Promise((resolve) => {
let fileDownloadPromise;
const fileType = FileUtils.getFileType(fileUrl);
Expand All @@ -82,6 +116,11 @@ const fileDownload: FileDownload = (fileUrl, fileName, successMessage) =>
fileDownloadPromise = downloadVideo(fileUrl, attachmentName);
break;
default:
if (requestType === CONST.NETWORK.METHOD.POST) {
fileDownloadPromise = postDownloadFile(fileUrl, fileName, formData, onDownloadFailed);
break;
}

fileDownloadPromise = downloadFile(fileUrl, attachmentName);
break;
}
Expand Down
63 changes: 3 additions & 60 deletions src/libs/fileDownload/index.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,10 @@
import * as ApiUtils from '@libs/ApiUtils';
import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
import * as Link from '@userActions/Link';
import CONST from '@src/CONST';
import * as FileUtils from './FileUtils';
import fetchFileDownload from './DownloadUtils';
import type {FileDownload} from './types';

/**
* The function downloads an attachment on web/desktop platforms.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const fileDownload: FileDownload = (url, fileName, successMessage = '', shouldOpenExternalLink = false, formData = undefined, requestType = 'get', onDownloadFailed?: () => void) => {
const resolvedUrl = tryResolveUrlFromApiRoot(url);
if (
// we have two file download cases that we should allow 1. dowloading attachments 2. downloading Expensify package for Sage Intacct
shouldOpenExternalLink ||
(!resolvedUrl.startsWith(ApiUtils.getApiRoot()) &&
!CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix)) &&
url !== CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT)
) {
// Different origin URLs might pose a CORS issue during direct downloads.
// Opening in a new tab avoids this limitation, letting the browser handle the download.
Link.openExternalLink(url);
return Promise.resolve();
}

const fetchOptions: RequestInit = {
method: requestType,
body: formData,
};

return fetch(url, fetchOptions)
.then((response) => response.blob())
.then((blob) => {
// Create blob link to download
const href = URL.createObjectURL(new Blob([blob]));

// creating anchor tag to initiate download
const link = document.createElement('a');

// adding href to anchor
link.href = href;
link.style.display = 'none';
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and since fileName can be an empty string we want to default to `FileUtils.getFileName(url)`
link.download = FileUtils.appendTimeToFileName(fileName || FileUtils.getFileName(url));

// Append to html link element page
document.body.appendChild(link);

// Start download
link.click();

// Clean up and remove the link
URL.revokeObjectURL(link.href);
link.parentNode?.removeChild(link);
})
.catch(() => {
if (onDownloadFailed) {
onDownloadFailed();
} else {
// file could not be downloaded, open sourceURL in new tab
Link.openExternalLink(url);
}
});
};
const fileDownload: FileDownload = (url, fileName, successMessage = '', shouldOpenExternalLink = false, formData = undefined, requestType = 'get', onDownloadFailed?: () => void) =>
fetchFileDownload(url, fileName, successMessage, shouldOpenExternalLink, formData, requestType, onDownloadFailed);

export default fileDownload;
Loading