From 14eb35927c12ac1807092637a04658cb5d9ef3de Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 5 Dec 2018 12:23:57 +0100 Subject: [PATCH] MOBILE-1748 core: Read file data in chunks to prevent crashes --- scripts/langindex.json | 1 + src/assets/lang/en.json | 1 + src/core/fileuploader/lang/en.json | 1 + src/core/fileuploader/providers/helper.ts | 53 +++++++++------ src/providers/file.ts | 83 ++++++++++++++++++++++- 5 files changed, 116 insertions(+), 23 deletions(-) diff --git a/scripts/langindex.json b/scripts/langindex.json index 392af6ed2..015df6814 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1309,6 +1309,7 @@ "core.fileuploader.more": "data", "core.fileuploader.photoalbums": "local_moodlemobileapp", "core.fileuploader.readingfile": "local_moodlemobileapp", + "core.fileuploader.readingfileperc": "local_moodlemobileapp", "core.fileuploader.selectafile": "local_moodlemobileapp", "core.fileuploader.uploadafile": "local_moodlemobileapp", "core.fileuploader.uploading": "local_moodlemobileapp", diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 45d914929..9e89479cf 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1309,6 +1309,7 @@ "core.fileuploader.more": "More", "core.fileuploader.photoalbums": "Photo albums", "core.fileuploader.readingfile": "Reading file", + "core.fileuploader.readingfileperc": "Reading file: {{$a}}%", "core.fileuploader.selectafile": "Select a file", "core.fileuploader.uploadafile": "Upload a file", "core.fileuploader.uploading": "Uploading", diff --git a/src/core/fileuploader/lang/en.json b/src/core/fileuploader/lang/en.json index 340786594..22d14df4a 100644 --- a/src/core/fileuploader/lang/en.json +++ b/src/core/fileuploader/lang/en.json @@ -20,6 +20,7 @@ "more": "More", "photoalbums": "Photo albums", "readingfile": "Reading file", + "readingfileperc": "Reading file: {{$a}}%", "selectafile": "Select a file", "uploadafile": "Upload a file", "uploading": "Uploading", diff --git a/src/core/fileuploader/providers/helper.ts b/src/core/fileuploader/providers/helper.ts index 75c4cd9fb..d530bd4be 100644 --- a/src/core/fileuploader/providers/helper.ts +++ b/src/core/fileuploader/providers/helper.ts @@ -13,12 +13,12 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { ActionSheetController, ActionSheet, Platform } from 'ionic-angular'; +import { ActionSheetController, ActionSheet, Platform, Loading } from 'ionic-angular'; import { MediaFile } from '@ionic-native/media-capture'; import { Camera, CameraOptions } from '@ionic-native/camera'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; -import { CoreFileProvider } from '@providers/file'; +import { CoreFileProvider, CoreFileProgressEvent } from '@providers/file'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; @@ -93,18 +93,15 @@ export class CoreFileUploaderHelperProvider { name = name || file.name; const modal = this.domUtils.showModalLoading('core.fileuploader.readingfile', true); - let fileData; - // We have the data of the file to be uploaded, but not its URL (needed). Create a copy of the file to upload it. - return this.fileProvider.readFileData(file, CoreFileProvider.FORMATARRAYBUFFER).then((data) => { - fileData = data; - - // Get unique name for the copy. - return this.fileProvider.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, name); - }).then((newName) => { + // Get unique name for the copy. + return this.fileProvider.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, name).then((newName) => { const filePath = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, newName); - return this.fileProvider.writeFile(filePath, fileData); + // Write the data into the file. + return this.fileProvider.writeFileDataInFile(file, filePath, (progress: CoreFileProgressEvent) => { + this.showProgressModal(modal, 'core.fileuploader.readingfileperc', progress); + }); }).catch((error) => { this.logger.error('Error reading file to upload.', error); modal.dismiss(); @@ -681,16 +678,7 @@ export class CoreFileUploaderHelperProvider { return this.fileUploaderProvider.uploadFile(path, options, (progress: ProgressEvent) => { // Progress uploading. - if (progress && progress.lengthComputable) { - const perc = Math.min((progress.loaded / progress.total) * 100, 100); - if (perc >= 0) { - modal.setContent(this.translate.instant('core.fileuploader.uploadingperc', { $a: perc.toFixed(1) })); - if (modal._cmp && modal._cmp.changeDetectorRef) { - // Force a change detection, otherwise the content is not updated. - modal._cmp.changeDetectorRef.detectChanges(); - } - } - } + this.showProgressModal(modal, 'core.fileuploader.uploadingperc', progress); }, siteId).catch((error) => { this.logger.error('Error uploading file.', error); @@ -705,4 +693,27 @@ export class CoreFileUploaderHelperProvider { }); }); } + + /** + * Show a progress modal. + * + * @param {Loading} modal The modal where to show the progress. + * @param {string} stringKey The key of the string to display. + * @param {ProgressEvent|CoreFileProgressEvent} progress The progress event. + */ + protected showProgressModal(modal: Loading, stringKey: string, progress: ProgressEvent | CoreFileProgressEvent): void { + if (progress && progress.lengthComputable) { + // Calculate the progress percentage. + const perc = Math.min((progress.loaded / progress.total) * 100, 100); + + if (perc >= 0) { + modal.setContent(this.translate.instant(stringKey, { $a: perc.toFixed(1) })); + + if (modal._cmp && modal._cmp.changeDetectorRef) { + // Force a change detection, otherwise the content is not updated. + modal._cmp.changeDetectorRef.detectChanges(); + } + } + } + } } diff --git a/src/providers/file.ts b/src/providers/file.ts index b48940aa0..08d4139ec 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -22,6 +22,29 @@ import { CoreMimetypeUtilsProvider } from './utils/mimetype'; import { CoreTextUtilsProvider } from './utils/text'; import { Zip } from '@ionic-native/zip'; +/** + * Progress event used when writing a file data into a file. + */ +export interface CoreFileProgressEvent { + /** + * Whether the values are reliabñe. + * @type {boolean} + */ + lengthComputable?: boolean; + + /** + * Number of treated bytes. + * @type {number} + */ + loaded?: number; + + /** + * Total of bytes. + * @type {number} + */ + total?: number; +} + /** * Factory to interact with the file system. */ @@ -41,6 +64,7 @@ export class CoreFileProvider { protected initialized = false; protected basePath = ''; protected isHTMLAPI = false; + protected CHUNK_SIZE = 10485760; // 10 MB. constructor(logger: CoreLoggerProvider, private platform: Platform, private file: File, private appProvider: CoreAppProvider, private textUtils: CoreTextUtilsProvider, private zip: Zip, private mimeUtils: CoreMimetypeUtilsProvider) { @@ -543,9 +567,10 @@ export class CoreFileProvider { * * @param {string} path Relative path to the file. * @param {any} data Data to write. + * @param {boolean} [append] Whether to append the data to the end of the file. * @return {Promise} Promise to be resolved when the file is written. */ - writeFile(path: string, data: any): Promise { + writeFile(path: string, data: any, append?: boolean): Promise { return this.init().then(() => { // Remove basePath if it's in the path. path = this.removeStartingSlash(path.replace(this.basePath, '')); @@ -560,13 +585,67 @@ export class CoreFileProvider { data = new Blob([data], { type: type || 'text/plain' }); } - return this.file.writeFile(this.basePath, path, data, { replace: true }).then(() => { + return this.file.writeFile(this.basePath, path, data, { replace: !append, append: !!append }).then(() => { return fileEntry; }); }); }); } + /** + * Write some file data into a filesystem file. + * It's done in chunks to prevent crashing the app for big files. + * + * @param {any} file The data to write. + * @param {string} path Path where to store the data. + * @param {Function} [onProgress] Function to call on progress. + * @param {number} [offset=0] Offset where to start reading from. + * @param {boolean} [append] Whether to append the data to the end of the file. + * @return {Promise} Promise resolved when done. + */ + writeFileDataInFile(file: any, path: string, onProgress?: (event: CoreFileProgressEvent) => any, offset: number = 0, + append?: boolean): Promise { + + offset = offset || 0; + + // Get the chunk to read. + const blob = file.slice(offset, Math.min(offset + this.CHUNK_SIZE, file.size)); + + return this.writeFileDataInFileChunk(blob, path, append).then((fileEntry) => { + offset += this.CHUNK_SIZE; + + onProgress && onProgress({ + lengthComputable: true, + loaded: offset, + total: file.size + }); + + if (offset >= file.size) { + // Done, stop. + return fileEntry; + } + + // Read the next chunk. + return this.writeFileDataInFile(file, path, onProgress, offset, true); + }); + } + + /** + * Write a chunk of data into a file. + * + * @param {any} chunkData The chunk of data. + * @param {string} path Path where to store the data. + * @param {boolean} [append] Whether to append the data to the end of the file. + * @return {Promise} Promise resolved when done. + */ + protected writeFileDataInFileChunk(chunkData: any, path: string, append?: boolean): Promise { + // Read the chunk data. + return this.readFileData(chunkData, CoreFileProvider.FORMATARRAYBUFFER).then((fileData) => { + // Write the data in the file. + return this.writeFile(path, fileData, append); + }); + } + /** * Gets a file that might be outside the app's folder. *