diff --git a/package-lock.json b/package-lock.json index 91276d9afef9..2ee34748b644 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5345,6 +5345,11 @@ "deep-assign": "^3.0.0" } }, + "@react-native-community/cameraroll": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cameraroll/-/cameraroll-4.1.2.tgz", + "integrity": "sha512-jkdhMByMKD2CZ/5MPeBieYn8vkCfC4MOTouPpBpps3I8N6HUYJk+1JnDdktVYl2WINnqXpQptDA2YptVyifYAg==" + }, "@react-native-community/cli": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-6.2.0.tgz", diff --git a/package.json b/package.json index a544e927531d..891271de2b5b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@formatjs/intl-pluralrules": "^4.0.13", "@onfido/react-native-sdk": "^2.2.0", "@react-native-async-storage/async-storage": "^1.15.5", + "@react-native-community/cameraroll": "^4.1.2", "@react-native-community/cli": "6.2.0", "@react-native-community/clipboard": "^1.5.1", "@react-native-community/datetimepicker": "^3.5.2", diff --git a/src/CONST.js b/src/CONST.js index 073d671b65e0..894cf295b27c 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -407,6 +407,17 @@ const CONST = { IMAGE: 'image', }, + ATTACHMENT_FILE_TYPE: { + FILE: 'file', + IMAGE: 'image', + VIDEO: 'video', + }, + + FILE_TYPE_REGEX: { + IMAGE: /\.(jpg|jpeg|png|webp|avif|gif|tiff|wbmp|ico|jng|bmp|heic|svg|svg2)$/, + VIDEO: /\.(3gp|h261|h263|h264|m4s|jpgv|jpm|jpgm|mp4|mp4v|mpg4|mpeg|mpg|ogv|ogg|mov|qt|webm|flv|mkv|wmv|wav|avi|movie|f4v|avchd|mp2|mpe|mpv|m4v|swf)$/, + }, + IOS_CAMERAROLL_ACCESS_ERROR: 'Access to photo library was denied', ADD_PAYMENT_MENU_POSITION_Y: 226, ADD_PAYMENT_MENU_POSITION_X: 356, EMOJI_PICKER_SIZE: { diff --git a/src/languages/en.js b/src/languages/en.js index 31a9c476856d..5bb7ce47b42d 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -979,4 +979,18 @@ export default { }, refresh: 'Refresh', }, + fileDownload: { + success: { + title: 'Downloaded!', + message: 'Attachment successfully downloaded!', + }, + generalError: { + title: 'Attachment Error', + message: 'Attachment cannot be downloaded', + }, + permissionError: { + title: 'Access needed', + message: 'Expensify does not have access to save attachments. To enable access, go to Settings and allow access', + }, + }, }; diff --git a/src/languages/es.js b/src/languages/es.js index 646631b14dc3..833d71fc08d4 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -981,4 +981,18 @@ export default { }, refresh: 'Refresh', }, + fileDownload: { + success: { + title: 'Descargado!', + message: 'Archivo descargado correctamente', + }, + generalError: { + title: 'Error en la descarga', + message: 'No se puede descargar el archivo adjunto', + }, + permissionError: { + title: 'Se necesita acceso', + message: 'Expensify no tiene acceso para guardar archivos. Para habilitar la descarga de archivos, entra en Settings y habilita el accesso', + }, + }, }; diff --git a/src/libs/fileDownload/FileUtils.js b/src/libs/fileDownload/FileUtils.js new file mode 100644 index 000000000000..5271216fa741 --- /dev/null +++ b/src/libs/fileDownload/FileUtils.js @@ -0,0 +1,112 @@ +import {Alert, Linking} from 'react-native'; +import moment from 'moment'; +import CONST from '../../CONST'; +import * as Localize from '../Localize'; + +/** + * Show alert on successful attachment download + */ +function showSuccessAlert() { + Alert.alert( + Localize.translateLocal('fileDownload.success.title'), + Localize.translateLocal('fileDownload.success.message'), + [ + { + text: Localize.translateLocal('common.ok'), + style: 'cancel', + }, + ], + {cancelable: false}, + ); +} + +/** + * Show alert on attachment download error + */ +function showGeneralErrorAlert() { + Alert.alert( + Localize.translateLocal('fileDownload.generalError.title'), + Localize.translateLocal('fileDownload.generalError.message'), + [ + { + text: Localize.translateLocal('common.cancel'), + style: 'cancel', + }, + ], + ); +} + +/** + * Show alert on attachment download permissions error + */ +function showPermissionErrorAlert() { + Alert.alert( + Localize.translateLocal('fileDownload.permissionError.title'), + Localize.translateLocal('fileDownload.permissionError.message'), + [ + { + text: Localize.translateLocal('common.cancel'), + style: 'cancel', + }, + { + text: Localize.translateLocal('common.settings'), + onPress: () => Linking.openSettings(), + }, + ], + ); +} + +/** + * Generate a random file name with timestamp and file extension + * @param {String} url + * @returns {String} + */ +function getAttachmentName(url) { + if (!url) { + return ''; + } + return `${moment().format('DDMMYYYYHHmmss')}.${url.split(/[#?]/)[0].split('.').pop().trim()}`; +} + +/** + * @param {String} fileName + * @returns {Boolean} + */ +function isImage(fileName) { + return CONST.FILE_TYPE_REGEX.IMAGE.test(fileName); +} + +/** + * @param {String} fileName + * @returns {Boolean} + */ +function isVideo(fileName) { + return CONST.FILE_TYPE_REGEX.VIDEO.test(fileName); +} + +/** + * Returns file type based on the uri + * @param {String} fileUrl + * @returns {String} + */ +function getFileType(fileUrl) { + if (!fileUrl) { + return; + } + const fileName = fileUrl.split('/').pop().split('?')[0].split('#')[0]; + if (isImage(fileName)) { + return CONST.ATTACHMENT_FILE_TYPE.IMAGE; + } + if (isVideo(fileName)) { + return CONST.ATTACHMENT_FILE_TYPE.VIDEO; + } + return CONST.ATTACHMENT_FILE_TYPE.FILE; +} + +export { + showGeneralErrorAlert, + showSuccessAlert, + showPermissionErrorAlert, + getAttachmentName, + getFileType, +}; diff --git a/src/libs/fileDownload/getAttachmentName.js b/src/libs/fileDownload/getAttachmentName.js deleted file mode 100644 index 54d0e38275b8..000000000000 --- a/src/libs/fileDownload/getAttachmentName.js +++ /dev/null @@ -1,13 +0,0 @@ -import moment from 'moment'; - -/** - * Generating a random file name with timestamp and file extention - * @param {String} url - * @returns {String} - */ -export default function getAttachmentName(url) { - if (!url) { - return ''; - } - return `${moment().format('DDMMYYYYHHmmss')}.${url.split(/[#?]/)[0].split('.').pop().trim()}`; -} diff --git a/src/libs/fileDownload/index.android.js b/src/libs/fileDownload/index.android.js new file mode 100644 index 000000000000..5e20089392e8 --- /dev/null +++ b/src/libs/fileDownload/index.android.js @@ -0,0 +1,83 @@ +import {PermissionsAndroid} from 'react-native'; +import RNFetchBlob from 'rn-fetch-blob'; +import * as FileUtils from './FileUtils'; + +/** + * Android permission check to store images + * @returns {Promise} + */ +function hasAndroidPermission() { + // Read and write permission + const writePromise = PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE); + const readPromise = PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE); + + return Promise.all([writePromise, readPromise]).then(([hasWritePermission, hasReadPermission]) => { + if (hasWritePermission && hasReadPermission) { + return true; // Return true if permission is already given + } + + // Ask for permission if not given + return PermissionsAndroid.requestMultiple([ + PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, + ]).then(status => status['android.permission.READ_EXTERNAL_STORAGE'] === 'granted' + && status['android.permission.WRITE_EXTERNAL_STORAGE'] === 'granted'); + }); +} + +/** + * Handling the download + * @param {String} url + * @param {String} fileName + * @returns {Promise} + */ +function handleDownload(url, fileName) { + return new Promise((resolve) => { + const dirs = RNFetchBlob.fs.dirs; + + // Android files will download to Download directory + const path = dirs.DownloadDir; + const attachmentName = fileName || FileUtils.getAttachmentName(url); + + // Fetching the attachment + const fetchedAttachment = RNFetchBlob.config({ + fileCache: true, + path: `${path}/${attachmentName}`, + addAndroidDownloads: { + useDownloadManager: true, + notification: true, + path: `${path}/Expensify/${attachmentName}`, + }, + }).fetch('GET', url); + + // Resolving the fetched attachment + fetchedAttachment.then((attachment) => { + if (!attachment || !attachment.info()) { + return; + } + + FileUtils.showSuccessAlert(); + }).catch(() => { + FileUtils.showGeneralErrorAlert(); + }).finally(() => resolve()); + }); +} + +/** + * Checks permission and downloads the file for Android + * @param {String} url + * @param {String} fileName + * @returns {Promise} + */ +export default function fileDownload(url, fileName) { + return new Promise((resolve) => { + hasAndroidPermission().then((hasPermission) => { + if (hasPermission) { + return handleDownload(url, fileName); + } + FileUtils.showPermissionErrorAlert(); + }).catch(() => { + FileUtils.showPermissionErrorAlert(); + }).finally(() => resolve()); + }); +} diff --git a/src/libs/fileDownload/index.ios.js b/src/libs/fileDownload/index.ios.js new file mode 100644 index 000000000000..7eaa5e43d895 --- /dev/null +++ b/src/libs/fileDownload/index.ios.js @@ -0,0 +1,104 @@ +import RNFetchBlob from 'rn-fetch-blob'; +import CameraRoll from '@react-native-community/cameraroll'; +import lodashGet from 'lodash/get'; +import * as FileUtils from './FileUtils'; +import CONST from '../../CONST'; + +/** + * Downloads the file to Documents section in iOS + * @param {String} fileUrl + * @param {String} fileName + * @returns {Promise} + */ +function downloadFile(fileUrl, fileName) { + const dirs = RNFetchBlob.fs.dirs; + + // The iOS files will download to documents directory + const path = dirs.DocumentDir; + + // Fetching the attachment + const fetchedAttachment = RNFetchBlob.config({ + fileCache: true, + path: `${path}/${fileName}`, + addAndroidDownloads: { + useDownloadManager: true, + notification: true, + path: `${path}/Expensify/${fileName}`, + }, + }).fetch('GET', fileUrl); + return fetchedAttachment; +} + +/** + * Download the image to photo lib in iOS + * @param {String} fileUrl + * @param {String} fileName + * @returns {String} URI + */ +function downloadImage(fileUrl) { + return CameraRoll.save(fileUrl); +} + +/** + * Download the video to photo lib in iOS + * @param {String} fileUrl + * @param {String} fileName + * @returns {String} URI + */ +function downloadVideo(fileUrl, fileName) { + return new Promise((resolve) => { + let documentPathUri = null; + let cameraRollUri = null; + + // Because CameraRoll doesn't allow direct downloads of video with remote URIs, we first download as documents, then copy to photo lib and unlink the original file. + downloadFile(fileUrl, fileName).then((attachment) => { + documentPathUri = lodashGet(attachment, 'data'); + return CameraRoll.save(documentPathUri); + }).then((attachment) => { + cameraRollUri = attachment; + return RNFetchBlob.fs.unlink(documentPathUri); + }).then(() => resolve(cameraRollUri)); + }); +} + +/** + * Download the file based on type(image, video, other file types)for iOS + * @param {String} fileUrl + * @param {String} fileName + * @returns {Promise} + */ +export default function fileDownload(fileUrl, fileName) { + return new Promise((resolve) => { + let fileDownloadPromise = null; + const fileType = FileUtils.getFileType(fileUrl); + const attachmentName = fileName || FileUtils.getAttachmentName(fileUrl); + + switch (fileType) { + case CONST.ATTACHMENT_FILE_TYPE.IMAGE: + fileDownloadPromise = downloadImage(fileUrl, attachmentName); + break; + case CONST.ATTACHMENT_FILE_TYPE.VIDEO: + fileDownloadPromise = downloadVideo(fileUrl, attachmentName); + break; + default: + fileDownloadPromise = downloadFile(fileUrl, attachmentName); + break; + } + + fileDownloadPromise.then((attachment) => { + if (!attachment) { + return; + } + + FileUtils.showSuccessAlert(); + }).catch((err) => { + // iOS shows permission popup only once. Subsequent request will only throw an error. + // We catch the error and show a redirection link to the settings screen + if (err.message === CONST.IOS_CAMERAROLL_ACCESS_ERROR) { + FileUtils.showPermissionErrorAlert(); + } else { + FileUtils.showGeneralErrorAlert(); + } + }).finally(() => resolve()); + }); +} diff --git a/src/libs/fileDownload/index.js b/src/libs/fileDownload/index.js index 5c7c7a50c991..9e6dc76dbf66 100644 --- a/src/libs/fileDownload/index.js +++ b/src/libs/fileDownload/index.js @@ -1,5 +1,5 @@ import {Linking} from 'react-native'; -import getAttachmentName from './getAttachmentName'; +import * as FileUtils from './FileUtils'; /** * Downloading attachment in web, desktop @@ -25,7 +25,7 @@ export default function fileDownload(url, fileName) { link.style.display = 'none'; link.setAttribute( 'download', - fileName || getAttachmentName(url), // generating the file name + fileName || FileUtils.getAttachmentName(url), // generating the file name ); // Append to html link element page diff --git a/src/libs/fileDownload/index.native.js b/src/libs/fileDownload/index.native.js deleted file mode 100644 index dd5084ca0679..000000000000 --- a/src/libs/fileDownload/index.native.js +++ /dev/null @@ -1,150 +0,0 @@ -import {Alert, Linking, PermissionsAndroid} from 'react-native'; -import RNFetchBlob from 'rn-fetch-blob'; -import getPlatform from '../getPlatform'; -import getAttachmentName from './getAttachmentName'; - -/** - * Android permission check to store images - * @returns{Promise} - */ -function hasAndroidPermission() { - return new Promise((resolve, reject) => { - // read and write permission - const readPermission = PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE; - const writePermission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE; - - const writePromise = PermissionsAndroid.check(writePermission); - const readPromise = PermissionsAndroid.check(readPermission); - - Promise.all([writePromise, readPromise]).then(([hasWritePermission, hasReadPermission]) => { - if (hasWritePermission && hasReadPermission) { - resolve(true); // return true if permission is already given - return; - } - - // ask for permission if not given - PermissionsAndroid.requestMultiple([ - readPermission, - writePermission, - ]).then((status) => { - resolve(status['android.permission.READ_EXTERNAL_STORAGE'] === 'granted' - && status['android.permission.WRITE_EXTERNAL_STORAGE'] === 'granted'); - }); - }).catch(error => reject(error)); - }); -} - -/** - * Re useable alert function - * @param {Object} content - */ -function showAlert(content) { - Alert.alert( - content.title || '', - content.message || '', - content.options || [], - {cancelable: false}, - ); -} - -/** - * Handling the download - * @param {String} url - * @param {String} fileName - * @returns {Promise} - */ -function handleDownload(url, fileName) { - return new Promise((resolve) => { - const dirs = RNFetchBlob.fs.dirs; - - // android files will download to Download directory - // ios files will download to documents directory - const path = getPlatform() === 'android' ? dirs.DownloadDir : dirs.DocumentDir; - const attachmentName = fileName || getAttachmentName(url); - - // fetching the attachment - const fetchedAttachment = RNFetchBlob.config({ - fileCache: true, - path: `${path}/${attachmentName}`, - addAndroidDownloads: { - useDownloadManager: true, - notification: true, - path: `${path}/Expensify/${attachmentName}`, - }, - }).fetch('GET', url); - - // resolving the fetched attachment - fetchedAttachment.then((attachment) => { - if (!attachment || !attachment.info()) { - return; - } - - showAlert({ - title: 'Downloaded!', - message: 'Attachment successfully downloaded', - options: [ - { - text: 'OK', - style: 'cancel', - }, - ], - }); - return resolve(); - }).catch(() => { - showAlert({ - title: 'Attachment Error', - message: 'Attachment cannot be downloaded', - options: [ - { - text: 'Cancel', - style: 'cancel', - }, - ], - }); - return resolve(); - }); - }); -} - -/** - * Platform specifically check download - * @param {String} url - * @param {String} fileName - * @returns {Promise} fileName - */ -export default function fileDownload(url, fileName) { - return new Promise((resolve) => { - const permissionError = { - title: 'Access Needed', - // eslint-disable-next-line max-len - message: 'NewExpensify does not have access to save attachments. To enable access, tap Settings and allow access.', - options: [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Settings', - onPress: () => Linking.openSettings(), - }, - ], - }; - - // permission check for android - if (getPlatform() === 'android') { - hasAndroidPermission().then((hasPermission) => { - if (hasPermission) { - handleDownload(url, fileName).then(() => resolve()); - } else { - showAlert(permissionError); - } - return resolve(); - }).catch(() => { - showAlert(permissionError); - return resolve(); - }); - } else { - handleDownload(url, fileName).then(() => resolve()); - } - }); -}