From ec3a1bdb56a517fa39bd9502c887e27582dbb590 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 7 Apr 2020 12:22:47 +0200 Subject: [PATCH 1/2] MOBILE-3270 core: Fix upload files from Google Drive --- src/components/local-file/local-file.ts | 2 +- .../pages/capture-media/capture-media.ts | 21 ++++++++---- src/providers/file.ts | 33 ++++++++++++------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/components/local-file/local-file.ts b/src/components/local-file/local-file.ts index c9f63ed17..cd0c573a3 100644 --- a/src/components/local-file/local-file.ts +++ b/src/components/local-file/local-file.ts @@ -78,7 +78,7 @@ export class CoreLocalFileComponent implements OnInit { this.size = this.textUtils.bytesToSize(metadata.size, 2); } - this.timemodified = this.timeUtils.userDate(metadata.modificationTime, 'core.strftimedatetimeshort'); + this.timemodified = this.timeUtils.userDate(metadata.modificationTime.getTime(), 'core.strftimedatetimeshort'); }); } diff --git a/src/core/emulator/pages/capture-media/capture-media.ts b/src/core/emulator/pages/capture-media/capture-media.ts index 77c1ec99e..89cbbbdab 100644 --- a/src/core/emulator/pages/capture-media/capture-media.ts +++ b/src/core/emulator/pages/capture-media/capture-media.ts @@ -18,6 +18,7 @@ import { CoreFileProvider } from '@providers/file'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { MediaFile } from '@ionic-native/media-capture'; /** * Page to capture media in browser or desktop. @@ -364,13 +365,21 @@ export class CoreEmulatorCaptureMediaPage implements OnInit, OnDestroy { if (this.isImage && !this.isCaptureImage) { this.dismissWithData(fileEntry.toURL()); } else { - // The capture plugin returns a MediaFile, not a FileEntry. - // The only difference is that it supports a new function that won't be supported in desktop. - fileEntry.getFormatData = (successFn, errorFn): any => { - // Nothing to do. - }; + // The capture plugin should return a MediaFile, not a FileEntry. Convert it. + return this.fileProvider.getMetadata(fileEntry).then((metadata) => { + const mediaFile: MediaFile = { + name: fileEntry.name, + fullPath: fileEntry.fullPath, + type: null, + lastModifiedDate: metadata.modificationTime, + size: metadata.size, + getFormatData: (successFn, errorFn): void => { + // Nothing to do. + } + }; - this.dismissWithData([fileEntry]); + this.dismissWithData([mediaFile]); + }); } }).catch((err) => { this.domUtils.showErrorModal(err); diff --git a/src/providers/file.ts b/src/providers/file.ts index 1bee27880..658560c42 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -14,7 +14,7 @@ import { Injectable } from '@angular/core'; import { Platform } from 'ionic-angular'; -import { File, FileEntry, DirectoryEntry } from '@ionic-native/file'; +import { File, FileEntry, DirectoryEntry, Entry, Metadata } from '@ionic-native/file'; import { CoreAppProvider } from './app'; import { CoreLoggerProvider } from './logger'; @@ -545,7 +545,7 @@ export class CoreFileProvider { reader.onloadend = (evt): void => { const target = evt.target; // Convert to to be able to use non-standard properties. - if (target.result !== undefined || target.result !== null) { + if (target.result !== undefined && target.result !== null) { if (format == CoreFileProvider.FORMATJSON) { // Convert to object. const parsed = this.textUtils.parseJSON(target.result, null); @@ -558,7 +558,7 @@ export class CoreFileProvider { } else { resolve(target.result); } - } else if (target.error !== undefined || target.error !== null) { + } else if (target.error !== undefined && target.error !== null) { reject(target.error); } else { reject({ code: null, message: 'READER_ONLOADEND_ERR' }); @@ -602,7 +602,7 @@ export class CoreFileProvider { * @param append Whether to append the data to the end of the file. * @return Promise to be resolved when the file is written. */ - writeFile(path: string, data: any, append?: boolean): 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, '')); @@ -635,15 +635,18 @@ export class CoreFileProvider { * @param append Whether to append the data to the end of the file. * @return Promise resolved when done. */ - writeFileDataInFile(file: any, path: string, onProgress?: (event: CoreFileProgressEvent) => any, offset: number = 0, - append?: boolean): Promise { + async writeFileDataInFile(file: Blob, path: string, onProgress?: (event: CoreFileProgressEvent) => void, 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)); + const readWholeFile = offset === 0 && this.CHUNK_SIZE >= file.size; + const chunk = readWholeFile ? file : file.slice(offset, Math.min(offset + this.CHUNK_SIZE, file.size)); + + try { + const fileEntry = await this.writeFileDataInFileChunk(chunk, path, append); - return this.writeFileDataInFileChunk(blob, path, append).then((fileEntry) => { offset += this.CHUNK_SIZE; onProgress && onProgress({ @@ -659,7 +662,15 @@ export class CoreFileProvider { // Read the next chunk. return this.writeFileDataInFile(file, path, onProgress, offset, true); - }); + } catch (error) { + if (readWholeFile || !error || error.name != 'NotReadableError') { + return Promise.reject(error); + } + + // Permission error when reading file in chunks. This usually happens with Google Drive files. + // Try to read the whole file at once. + return this.writeFileDataInFileChunk(file, path, false); + } } /** @@ -670,7 +681,7 @@ export class CoreFileProvider { * @param append Whether to append the data to the end of the file. * @return Promise resolved when done. */ - protected writeFileDataInFileChunk(chunkData: any, path: string, append?: boolean): Promise { + 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. @@ -1053,7 +1064,7 @@ export class CoreFileProvider { * @param fileEntry FileEntry retrieved from getFile or similar. * @return Promise resolved with metadata. */ - getMetadata(fileEntry: Entry): Promise { + getMetadata(fileEntry: Entry): Promise { if (!fileEntry || !fileEntry.getMetadata) { return Promise.reject(null); } From 4deaf3be80560ff2c7688b84961a5a91c8ee4112 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 8 Apr 2020 08:27:43 +0200 Subject: [PATCH 2/2] MOBILE-3270 core: Show warning if app crashed when uploading big file --- scripts/langindex.json | 1 + src/assets/lang/en.json | 1 + .../fileuploader/providers/file-handler.ts | 16 +- src/core/fileuploader/providers/helper.ts | 27 ++-- src/lang/en.json | 1 + src/providers/file-helper.ts | 146 +++++++++++++++++- src/providers/file.ts | 31 ++-- upgrade.txt | 4 + 8 files changed, 198 insertions(+), 29 deletions(-) diff --git a/scripts/langindex.json b/scripts/langindex.json index 4810e4705..c275a91ed 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1373,6 +1373,7 @@ "core.confirmgotabrootdefault": "local_moodlemobileapp", "core.confirmloss": "local_moodlemobileapp", "core.confirmopeninbrowser": "local_moodlemobileapp", + "core.confirmreadfiletoobig": "local_moodlemobileapp", "core.considereddigitalminor": "moodle", "core.content": "moodle", "core.contenteditingsynced": "local_moodlemobileapp", diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 080e14b1c..7966066ec 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1373,6 +1373,7 @@ "core.confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", "core.confirmloss": "Are you sure? All changes will be lost.", "core.confirmopeninbrowser": "Do you want to open it in a web browser?", + "core.confirmreadfiletoobig": "It seems the app previously crashed when trying to read a file as big as this one. Are you sure you want to continue?

If this file is stored in a repository like Google Drive, please try to download the file to your device first and use the downloaded file.", "core.considereddigitalminor": "You are too young to create an account on this site.", "core.content": "Content", "core.contenteditingsynced": "The content you are editing has been synced.", diff --git a/src/core/fileuploader/providers/file-handler.ts b/src/core/fileuploader/providers/file-handler.ts index 9d4c92462..c89eaa03d 100644 --- a/src/core/fileuploader/providers/file-handler.ts +++ b/src/core/fileuploader/providers/file-handler.ts @@ -20,6 +20,7 @@ import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreFileUploaderHandler, CoreFileUploaderHandlerData } from './delegate'; import { CoreFileUploaderHelperProvider } from './helper'; import { CoreFileUploaderProvider } from './fileuploader'; +import { TranslateService } from '@ngx-translate/core'; /** * Handler to upload any type of file. */ @@ -28,9 +29,13 @@ export class CoreFileUploaderFileHandler implements CoreFileUploaderHandler { name = 'CoreFileUploaderFile'; priority = 1200; - constructor(private appProvider: CoreAppProvider, private platform: Platform, private timeUtils: CoreTimeUtilsProvider, - private uploaderHelper: CoreFileUploaderHelperProvider, private uploaderProvider: CoreFileUploaderProvider, - private domUtils: CoreDomUtilsProvider) { } + constructor(protected appProvider: CoreAppProvider, + protected platform: Platform, + protected timeUtils: CoreTimeUtilsProvider, + protected uploaderHelper: CoreFileUploaderHelperProvider, + protected uploaderProvider: CoreFileUploaderProvider, + protected domUtils: CoreDomUtilsProvider, + protected translate: TranslateService) { } /** * Whether or not the handler is enabled on a site level. @@ -107,9 +112,8 @@ export class CoreFileUploaderFileHandler implements CoreFileUploaderHandler { this.uploaderHelper.uploadFileObject(file, maxSize, upload, allowOffline, fileName).then((result) => { this.uploaderHelper.fileUploaded(result); }).catch((error) => { - if (error) { - this.domUtils.showErrorModal(error); - } + this.domUtils.showErrorModalDefault(error, + this.translate.instant('core.fileuploader.errorreadingfile')); }); }); diff --git a/src/core/fileuploader/providers/helper.ts b/src/core/fileuploader/providers/helper.ts index afaa6f909..903a5fda6 100644 --- a/src/core/fileuploader/providers/helper.ts +++ b/src/core/fileuploader/providers/helper.ts @@ -19,6 +19,7 @@ import { Camera, CameraOptions } from '@ionic-native/camera'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; import { CoreFileProvider, CoreFileProgressEvent } from '@providers/file'; +import { CoreFileHelperProvider } from '@providers/file-helper'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; @@ -36,11 +37,19 @@ export class CoreFileUploaderHelperProvider { protected filePickerDeferred: PromiseDefer; protected actionSheet: ActionSheet; - constructor(logger: CoreLoggerProvider, private appProvider: CoreAppProvider, private translate: TranslateService, - private fileUploaderProvider: CoreFileUploaderProvider, private domUtils: CoreDomUtilsProvider, - private textUtils: CoreTextUtilsProvider, private fileProvider: CoreFileProvider, private utils: CoreUtilsProvider, - private actionSheetCtrl: ActionSheetController, private uploaderDelegate: CoreFileUploaderDelegate, - private camera: Camera, private platform: Platform) { + constructor(logger: CoreLoggerProvider, + protected appProvider: CoreAppProvider, + protected translate: TranslateService, + protected fileUploaderProvider: CoreFileUploaderProvider, + protected domUtils: CoreDomUtilsProvider, + protected textUtils: CoreTextUtilsProvider, + protected fileProvider: CoreFileProvider, + protected utils: CoreUtilsProvider, + protected actionSheetCtrl: ActionSheetController, + protected uploaderDelegate: CoreFileUploaderDelegate, + protected camera: Camera, + protected platform: Platform, + protected fileHelper: CoreFileHelperProvider) { this.logger = logger.getInstance('CoreFileUploaderProvider'); } @@ -99,14 +108,14 @@ export class CoreFileUploaderHelperProvider { const filePath = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, newName); // Write the data into the file. - return this.fileProvider.writeFileDataInFile(file, filePath, (progress: CoreFileProgressEvent) => { + return this.fileHelper.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(); - return Promise.reject(this.translate.instant('core.fileuploader.errorreadingfile')); + return Promise.reject(error); }).then((fileEntry) => { modal.dismiss(); @@ -319,9 +328,7 @@ export class CoreFileUploaderHelperProvider { // Success uploading or picking, return the result. this.fileUploaded(result); }).catch((error) => { - if (error) { - this.domUtils.showErrorModal(error); - } + this.domUtils.showErrorModalDefault(error, this.translate.instant('core.fileuploader.errorreadingfile')); }); // Do not close the action sheet, it will be closed if success. diff --git a/src/lang/en.json b/src/lang/en.json index 3b9ccff0f..60d16355c 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -43,6 +43,7 @@ "confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", "confirmloss": "Are you sure? All changes will be lost.", "confirmopeninbrowser": "Do you want to open it in a web browser?", + "confirmreadfiletoobig": "It seems the app previously crashed when trying to read a file as big as this one. Are you sure you want to continue?

If this file is stored in a repository like Google Drive, please try to download the file to your device first and use the downloaded file.", "considereddigitalminor": "You are too young to create an account on this site.", "content": "Content", "contenteditingsynced": "The content you are editing has been synced.", diff --git a/src/providers/file-helper.ts b/src/providers/file-helper.ts index 00b4b4150..31a2e6e70 100644 --- a/src/providers/file-helper.ts +++ b/src/providers/file-helper.ts @@ -15,12 +15,15 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from './app'; -import { CoreFileProvider } from './file'; +import { CoreConfigProvider } from './config'; +import { CoreFileProvider, CoreFileProgressFunction } from './file'; import { CoreFilepoolProvider } from './filepool'; import { CoreSitesProvider } from './sites'; import { CoreWSProvider } from './ws'; +import { CoreDomUtilsProvider } from './utils/dom'; import { CoreUtilsProvider } from './utils/utils'; import { CoreConstants } from '@core/constants'; +import { FileEntry } from '@ionic-native/file'; import { makeSingleton } from '@singletons/core.singletons'; /** @@ -29,9 +32,23 @@ import { makeSingleton } from '@singletons/core.singletons'; @Injectable() export class CoreFileHelperProvider { - constructor(private fileProvider: CoreFileProvider, private filepoolProvider: CoreFilepoolProvider, - private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider, private translate: TranslateService, - private utils: CoreUtilsProvider, private wsProvider: CoreWSProvider) { } + // Variables for reading files in chunks. + protected MAX_CHUNK_SIZE_NAME = 'file_max_chunk_size'; + protected READ_CHUNK_ATTEMPT_NAME = 'file_read_chunk_attempt'; + protected maxChunkSize = -1; + + constructor(protected fileProvider: CoreFileProvider, + protected filepoolProvider: CoreFilepoolProvider, + protected sitesProvider: CoreSitesProvider, + protected appProvider: CoreAppProvider, + protected translate: TranslateService, + protected utils: CoreUtilsProvider, + protected wsProvider: CoreWSProvider, + protected configProvider: CoreConfigProvider, + protected domUtils: CoreDomUtilsProvider) { + + this.initMaxChunkSize(); + } /** * Convenience function to open a file, downloading it if needed. @@ -240,6 +257,34 @@ export class CoreFileHelperProvider { return file.timemodified || 0; } + /** + * Initialize the max chunk size. + * + * @return Promise resolved when done. + */ + protected async initMaxChunkSize(): Promise { + const sizes = await Promise.all([ + await this.configProvider.get(this.READ_CHUNK_ATTEMPT_NAME, -1), // Check if there is any attempt pending. + await this.configProvider.get(this.MAX_CHUNK_SIZE_NAME, -1), // Retrieve current max chunk size from DB. + ]); + + const attemptSize = sizes[0]; + const maxChunkSize = sizes[1]; + + if (attemptSize != -1 && (maxChunkSize == -1 || attemptSize < maxChunkSize)) { + // Store the attempt's size as the max size. + this.storeMaxChunkSize(attemptSize); + } else { + // No attempt or the max size is already lower. Keep current max size. + this.maxChunkSize = maxChunkSize; + } + + if (attemptSize != -1) { + // Clean pending attempt. + await this.configProvider.delete(this.READ_CHUNK_ATTEMPT_NAME); + } + } + /** * Check if a state is downloaded or outdated. * @@ -334,6 +379,99 @@ export class CoreFileHelperProvider { throw new Error('Couldn\'t determine file size: ' + file.fileurl); } + + /** + * Save max chunk size. + * + * @param size Size to store. + * @return Promise resolved when done. + */ + protected async storeMaxChunkSize(size: number): Promise { + this.maxChunkSize = size; + + await this.configProvider.set(this.MAX_CHUNK_SIZE_NAME, size); + } + + /** + * Write some file data into a filesystem file. + * It's done in chunks to prevent crashing the app for big files. + * + * @param file The data to write. + * @param path Path where to store the data. + * @param onProgress Function to call on progress. + * @param offset Offset where to start reading from. + * @param append Whether to append the data to the end of the file. + * @return Promise resolved when done. + */ + async writeFileDataInFile(file: Blob, path: string, onProgress?: CoreFileProgressFunction, offset: number = 0, + append?: boolean): Promise { + + offset = offset || 0; + + // Get the chunk to read and write. + const readWholeFile = offset === 0 && CoreFileProvider.CHUNK_SIZE >= file.size; + const chunk = readWholeFile ? file : file.slice(offset, Math.min(offset + CoreFileProvider.CHUNK_SIZE, file.size)); + + try { + const fileEntry = await this.fileProvider.writeFileDataInFileChunk(chunk, path, append); + + offset += CoreFileProvider.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); + } catch (error) { + if (readWholeFile || !error || error.name != 'NotReadableError') { + return Promise.reject(error); + } + + // Permission error when reading file in chunks. This usually happens with Google Drive files. + // Try to read the whole file at once. + return this.writeBigFileDataInFile(file, path, onProgress); + } + } + + /** + * Writes a big file data into a filesystem file without using chunks. + * The app can crash when doing this with big files, so this function will try to control the max size that works + * and warn the user if he's trying to upload a file that is too big. + * + * @param file The data to write. + * @param path Path where to store the data. + * @param onProgress Function to call on progress. + * @return Promise resolved with the file. + */ + protected async writeBigFileDataInFile(file: Blob, path: string, onProgress?: CoreFileProgressFunction): Promise { + if (this.maxChunkSize != -1 && file.size >= this.maxChunkSize) { + // The file size is bigger than the max allowed size. Ask the user to confirm. + await this.domUtils.showConfirm(this.translate.instant('core.confirmreadfiletoobig')); + } + + // Store the "attempt". + await this.configProvider.set(this.READ_CHUNK_ATTEMPT_NAME, file.size); + + // Write the whole file. + const fileEntry = await this.fileProvider.writeFileDataInFileChunk(file, path, false); + + // Success, remove the attempt and increase the max chunk size if needed. + await this.configProvider.delete(this.READ_CHUNK_ATTEMPT_NAME); + + if (file.size > this.maxChunkSize) { + await this.storeMaxChunkSize(file.size + 1); + } + + return fileEntry; + } } export class CoreFileHelper extends makeSingleton(CoreFileHelperProvider) {} diff --git a/src/providers/file.ts b/src/providers/file.ts index 658560c42..5bc9140e8 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -44,6 +44,11 @@ export interface CoreFileProgressEvent { total?: number; } +/** + * Progress function. + */ +export type CoreFileProgressFunction = (event: CoreFileProgressEvent) => void; + /** * Factory to interact with the file system. */ @@ -60,14 +65,21 @@ export class CoreFileProvider { static SITESFOLDER = 'sites'; static TMPFOLDER = 'tmp'; + static CHUNK_SIZE = 10485760; // 10 MB. + protected logger; 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) { + constructor(logger: CoreLoggerProvider, + protected platform: Platform, + protected file: File, + protected appProvider: CoreAppProvider, + protected textUtils: CoreTextUtilsProvider, + protected zip: Zip, + protected mimeUtils: CoreMimetypeUtilsProvider) { + this.logger = logger.getInstance('CoreFileProvider'); if (platform.is('android') && !Object.getOwnPropertyDescriptor(FileReader.prototype, 'onloadend')) { @@ -634,20 +646,21 @@ export class CoreFileProvider { * @param offset Offset where to start reading from. * @param append Whether to append the data to the end of the file. * @return Promise resolved when done. + * @deprecated since 3.8.3. Please use CoreFileHelperProvider.writeFileDataInFile instead. */ - async writeFileDataInFile(file: Blob, path: string, onProgress?: (event: CoreFileProgressEvent) => void, offset: number = 0, + async writeFileDataInFile(file: Blob, path: string, onProgress?: CoreFileProgressFunction, offset: number = 0, append?: boolean): Promise { offset = offset || 0; - // Get the chunk to read. - const readWholeFile = offset === 0 && this.CHUNK_SIZE >= file.size; - const chunk = readWholeFile ? file : file.slice(offset, Math.min(offset + this.CHUNK_SIZE, file.size)); + // Get the chunk to read and write. + const readWholeFile = offset === 0 && CoreFileProvider.CHUNK_SIZE >= file.size; + const chunk = readWholeFile ? file : file.slice(offset, Math.min(offset + CoreFileProvider.CHUNK_SIZE, file.size)); try { const fileEntry = await this.writeFileDataInFileChunk(chunk, path, append); - offset += this.CHUNK_SIZE; + offset += CoreFileProvider.CHUNK_SIZE; onProgress && onProgress({ lengthComputable: true, @@ -681,7 +694,7 @@ export class CoreFileProvider { * @param append Whether to append the data to the end of the file. * @return Promise resolved when done. */ - protected writeFileDataInFileChunk(chunkData: any, path: string, append?: boolean): Promise { + writeFileDataInFileChunk(chunkData: Blob, path: string, append?: boolean): Promise { // Read the chunk data. return this.readFileData(chunkData, CoreFileProvider.FORMATARRAYBUFFER).then((fileData) => { // Write the data in the file. diff --git a/upgrade.txt b/upgrade.txt index 75333c0c4..f66263f6a 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,6 +1,10 @@ This files describes API changes in the Moodle Mobile app, information provided here is intended especially for developers. +=== 3.8.3 === + +- CoreFileProvider.writeFileDataInFile has been deprecated. Please use CoreFileHelperProvider.writeFileDataInFile instead. + === 3.8.0 === - CoreDomUtilsProvider.extractDownloadableFilesFromHtml and CoreDomUtilsProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects have been deprecated. Please use CoreFilepoolProvider.extractDownloadableFilesFromHtml and CoreFilepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects. We had to move them to prevent a circular dependency.