Skip to content

Commit

Permalink
Merge pull request #45593 from software-mansion-labs/search/export-cs…
Browse files Browse the repository at this point in the history
…v-native-devices

[Search] Export CSV on native devices
  • Loading branch information
rlinoz authored Jul 19, 2024
2 parents 064cbc7 + 093398c commit 584fabd
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 79 deletions.
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)
) {
// 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;

0 comments on commit 584fabd

Please sign in to comment.