Skip to content

Commit

Permalink
Merge pull request #7849 from mananjadhav/fix/ios-image-download
Browse files Browse the repository at this point in the history
  • Loading branch information
thienlnam authored May 4, 2022
2 parents 411e59c + 4066699 commit 60690ee
Show file tree
Hide file tree
Showing 11 changed files with 346 additions and 165 deletions.
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
14 changes: 14 additions & 0 deletions src/languages/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
};
14 changes: 14 additions & 0 deletions src/languages/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
};
112 changes: 112 additions & 0 deletions src/libs/fileDownload/FileUtils.js
Original file line number Diff line number Diff line change
@@ -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,
};
13 changes: 0 additions & 13 deletions src/libs/fileDownload/getAttachmentName.js

This file was deleted.

83 changes: 83 additions & 0 deletions src/libs/fileDownload/index.android.js
Original file line number Diff line number Diff line change
@@ -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<Boolean>}
*/
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<Void>}
*/
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<Void>}
*/
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());
});
}
104 changes: 104 additions & 0 deletions src/libs/fileDownload/index.ios.js
Original file line number Diff line number Diff line change
@@ -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<Void>}
*/
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());
});
}
Loading

0 comments on commit 60690ee

Please sign in to comment.