// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Injectable } from '@angular/core'; import { ActionSheetButton } from '@ionic/core'; import { CameraOptions } from '@ionic-native/camera/ngx'; import { ChooserResult } from '@ionic-native/chooser/ngx'; import { FileEntry, IFile } from '@ionic-native/file/ngx'; import { MediaFile } from '@ionic-native/media-capture/ngx'; import { CoreApp } from '@services/app'; import { CoreFile, CoreFileProvider, CoreFileProgressEvent } from '@services/file'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { makeSingleton, Translate, Camera, Chooser, Platform, ActionSheetController } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreError } from '@classes/errors/error'; import { CoreFileUploader, CoreFileUploaderProvider, CoreFileUploaderOptions } from './fileuploader'; import { CoreFileUploaderDelegate } from './fileuploader-delegate'; import { CoreCaptureError } from '@classes/errors/captureerror'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreWSUploadFileResult } from '@services/ws'; import { CoreSites } from '@services/sites'; import { CoreText } from '@singletons/text'; import { CorePromisedValue } from '@classes/promised-value'; /** * Helper service to upload files. */ @Injectable({ providedIn: 'root' }) export class CoreFileUploaderHelperProvider { protected logger: CoreLogger; protected filePickerDeferred?: CorePromisedValue; protected actionSheet?: HTMLIonActionSheetElement; constructor() { this.logger = CoreLogger.getInstance('CoreFileUploaderHelperProvider'); } /** * Choose any type of file and upload it. * * @param maxSize Max size of the upload. -1 for no max size. * @param upload True if the file should be uploaded, false to return the picked file. * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. * @param allowOffline True to allow uploading in offline. * @return Promise resolved when done. */ async chooseAndUploadFile( maxSize?: number, upload?: boolean, allowOffline?: boolean, mimetypes?: string[], ): Promise { const modal = await CoreDomUtils.showModalLoading(); const result = await Chooser.getFileMetadata(mimetypes ? mimetypes.join(',') : undefined); modal.dismiss(); if (!result) { // User canceled. throw new CoreCanceledError(); } if (result.name == 'File') { // In some Android 4.4 devices the file name cannot be retrieved. Try to use the one from the URI. result.name = this.getChosenFileNameFromPath(result) || result.name; } // Verify that the mimetype is supported. const error = CoreFileUploader.isInvalidMimetype(mimetypes, result.name, result.mediaType); if (error) { throw new CoreError(error); } const options = CoreFileUploader.getFileUploadOptions(result.uri, result.name, result.mediaType, true); if (upload) { return this.uploadFile(result.uri, maxSize || -1, true, options); } else { return this.copyToTmpFolder(result.uri, false, maxSize, undefined, options); } } /** * Show a confirmation modal to the user if the size of the file is bigger than the allowed threshold. * * @param size File size. * @param alwaysConfirm True to show a confirm even if the size isn't high. * @param allowOffline True to allow uploading in offline. * @param wifiThreshold Threshold for WiFi connection. Default: CoreFileUploaderProvider.WIFI_SIZE_WARNING. * @param limitedThreshold Threshold for limited connection. Default: CoreFileUploaderProvider.LIMITED_SIZE_WARNING. * @return Promise resolved when the user confirms or if there's no need to show a modal. */ async confirmUploadFile( size: number, alwaysConfirm?: boolean, allowOffline?: boolean, wifiThreshold?: number, limitedThreshold?: number, ): Promise { if (size == 0) { return; } if (!allowOffline && !CoreApp.isOnline()) { throw new CoreError(Translate.instant('core.fileuploader.errormustbeonlinetoupload')); } wifiThreshold = wifiThreshold === undefined ? CoreFileUploaderProvider.WIFI_SIZE_WARNING : wifiThreshold; limitedThreshold = limitedThreshold === undefined ? CoreFileUploaderProvider.LIMITED_SIZE_WARNING : limitedThreshold; if (size < 0) { return CoreDomUtils.showConfirm(Translate.instant('core.fileuploader.confirmuploadunknownsize')); } else if (size >= wifiThreshold || (CoreApp.isNetworkAccessLimited() && size >= limitedThreshold)) { const readableSize = CoreTextUtils.bytesToSize(size, 2); return CoreDomUtils.showConfirm( Translate.instant('core.fileuploader.confirmuploadfile', { size: readableSize }), ); } else if (alwaysConfirm) { return CoreDomUtils.showConfirm(Translate.instant('core.areyousure')); } } /** * Create a temporary copy of a file and upload it. * * @param file File to copy and upload. * @param upload True if the file should be uploaded, false to return the copy of the file. * @param name Name to use when uploading the file. If not defined, use the file's name. * @return Promise resolved when the file is uploaded. */ async copyAndUploadFile(file: IFile | File, upload?: boolean, name?: string): Promise { name = name || file.name; const modal = await CoreDomUtils.showModalLoading('core.fileuploader.readingfile', true); let fileEntry: FileEntry | undefined; try { // Get unique name for the copy. const newName = await CoreFile.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, name); const filePath = CoreText.concatenatePaths(CoreFileProvider.TMPFOLDER, newName); // Write the data into the file. fileEntry = await CoreFile.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(); throw error; } modal.dismiss(); if (upload) { // Pass true to delete the copy after the upload. return this.uploadGenericFile(fileEntry.toURL(), name, file.type, true); } else { return fileEntry; } } /** * Copy or move a file to the app temporary folder. * * @param path Path of the file. * @param shouldDelete True if original file should be deleted (move), false otherwise (copy). * @param maxSize Max size of the file. If not defined or -1, no max size. * @param defaultExt Defaut extension to use if the file doesn't have any. * @return Promise resolved with the copied file. */ protected async copyToTmpFolder( path: string, shouldDelete: boolean, maxSize?: number, defaultExt?: string, options?: CoreFileUploaderOptions, ): Promise { const fileName = options?.fileName || CoreFile.getFileAndDirectoryFromPath(path).name; // Check that size isn't too large. if (maxSize !== undefined && maxSize != -1) { const fileEntry = await CoreFile.getExternalFile(path); const fileData = await CoreFile.getFileObjectFromFileEntry(fileEntry); if (fileData.size > maxSize) { throw this.createMaxBytesError(maxSize, fileEntry.name); } } // File isn't too large. // Get a unique name in the folder to prevent overriding another file. const newName = await CoreFile.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, fileName, defaultExt); // Now move or copy the file. const destPath = CoreText.concatenatePaths(CoreFileProvider.TMPFOLDER, newName); if (shouldDelete) { return CoreFile.moveExternalFile(path, destPath); } else { return CoreFile.copyExternalFile(path, destPath); } } /** * Function called when trying to upload a file bigger than max size. Creates an error instance. * * @param maxSize Max size (bytes). * @param fileName Name of the file. * @return Message. */ protected createMaxBytesError(maxSize: number, fileName: string): CoreError { return new CoreError(Translate.instant('core.fileuploader.maxbytesfile', { $a: { file: fileName, size: CoreTextUtils.bytesToSize(maxSize, 2), }, })); } /** * Function called when the file picker is closed. */ filePickerClosed(): void { if (this.filePickerDeferred) { this.filePickerDeferred.reject(new CoreCanceledError()); this.filePickerDeferred = undefined; } } /** * Function to call once a file is uploaded using the file picker. * * @param result Result of the upload process. */ fileUploaded(result: CoreWSUploadFileResult | FileEntry): void { if (this.filePickerDeferred) { this.filePickerDeferred.resolve(result); this.filePickerDeferred = undefined; } // Close the action sheet if it's opened. this.actionSheet?.dismiss(); } /** * Given the result of choosing a file, try to get its file name from the path. * * @param result Chosen file data. * @return File name, undefined if cannot get it. */ protected getChosenFileNameFromPath(result: ChooserResult): string | undefined { const nameAndDir = CoreFile.getFileAndDirectoryFromPath(result.uri); if (!nameAndDir.name) { return; } let extension = CoreMimetypeUtils.getFileExtension(nameAndDir.name); if (!extension) { // The URI doesn't have an extension, add it now. extension = CoreMimetypeUtils.getExtension(result.mediaType); if (extension) { nameAndDir.name += '.' + extension; } } return decodeURIComponent(nameAndDir.name); } /** * Open the "file picker" to select and upload a file. * * @param maxSize Max size of the file to upload. If not defined or -1, no max size. * @param title File picker title. * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. * @return Promise resolved when a file is uploaded, rejected if file picker is closed without a file uploaded. * The resolve value is the response of the upload request. */ async selectAndUploadFile(maxSize?: number, title?: string, mimetypes?: string[]): Promise { return await this.selectFileWithPicker(maxSize, false, title, mimetypes, true); } /** * Open the "file picker" to select a file without uploading it. * * @param maxSize Max size of the file. If not defined or -1, no max size. * @param allowOffline True to allow selecting in offline, false to require connection. * @param title File picker title. * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. * @return Promise resolved when a file is selected, rejected if file picker is closed without selecting a file. * The resolve value is the FileEntry of a copy of the picked file, so it can be deleted afterwards. */ async selectFile(maxSize?: number, allowOffline?: boolean, title?: string, mimetypes?: string[]): Promise { return await this.selectFileWithPicker(maxSize, allowOffline, title, mimetypes, false); } /** * Open the "file picker" to select a file and maybe uploading it. * * @param maxSize Max size of the file. If not defined or -1, no max size. * @param allowOffline True to allow selecting in offline, false to require connection. * @param title File picker title. * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. * @param upload Whether the file should be uploaded. * @return Promise resolved when a file is selected/uploaded, rejected if file picker is closed. */ protected async selectFileWithPicker( maxSize?: number, allowOffline?: boolean, title?: string, mimetypes?: string[], upload?: boolean, ): Promise { // Create the cancel button and get the handlers to upload the file. const buttons: ActionSheetButton[] = [{ text: Translate.instant('core.cancel'), role: 'cancel', handler: (): void => { // User cancelled the action sheet. this.filePickerClosed(); }, }]; const handlers = CoreFileUploaderDelegate.getHandlers(mimetypes); this.filePickerDeferred = new CorePromisedValue(); // Create a button for each handler. handlers.forEach((handler) => { buttons.push({ text: Translate.instant(handler.title), icon: handler.icon, cssClass: handler.class, handler: async (): Promise => { if (!handler.action) { // Nothing to do. return false; } if (!allowOffline && !CoreApp.isOnline()) { // Not allowed, show error. CoreDomUtils.showErrorModal('core.fileuploader.errormustbeonlinetoupload', true); return false; } try { const data = await handler.action(maxSize, upload, allowOffline, handler.mimetypes); let result: CoreWSUploadFileResult | FileEntry | undefined; if (data.treated) { // The handler already treated the file. Return the result. result = data.result; } else if (data.fileEntry) { // The handler provided us a fileEntry, use it. result = await this.uploadFileEntry(data.fileEntry, !!data.delete, maxSize, upload, allowOffline); } else if (data.path) { let fileEntry: FileEntry; try { // The handler provided a path. First treat it like it's a relative path. fileEntry = await CoreFile.getFile(data.path); } catch (error) { // File not found, it's probably an absolute path. fileEntry = await CoreFile.getExternalFile(data.path); } // File found, treat it. result = await this.uploadFileEntry(fileEntry, !!data.delete, maxSize, upload, allowOffline); } if (!result) { // Nothing received, fail. throw new CoreError('No file received'); } this.fileUploaded(result); return true; } catch (error) { CoreDomUtils.showErrorModalDefault( error, Translate.instant('core.fileuploader.errorreadingfile'), ); return false; } }, }); }); this.actionSheet = await ActionSheetController.create({ header: title ? title : Translate.instant('core.fileuploader.' + (upload ? 'uploadafile' : 'selectafile')), buttons: buttons, }); await this.actionSheet.present(); // Call afterRender for each button. setTimeout(() => { handlers.forEach((handler) => { if (handler.afterRender) { handler.afterRender(maxSize, upload, allowOffline, handler.mimetypes); } }); }, 500); return this.filePickerDeferred; } /** * Convenience function to upload a file on a certain site, showing a confirm if needed. * * @param fileEntry FileEntry of the file to upload. * @param deleteAfterUpload Whether the file should be deleted after upload. * @param siteId Id of the site to upload the file to. If not defined, use current site. * @return Promise resolved when the file is uploaded. */ async showConfirmAndUploadInSite(fileEntry: FileEntry, deleteAfterUpload?: boolean, siteId?: string): Promise { try { const file = await CoreFile.getFileObjectFromFileEntry(fileEntry); await this.confirmUploadFile(file.size); await this.uploadGenericFile(fileEntry.toURL(), file.name, file.type, deleteAfterUpload, siteId); CoreDomUtils.showToast('core.fileuploader.fileuploaded', true, undefined, 'core-toast-success'); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'core.fileuploader.errorreadingfile', true); throw error; } } /** * Treat a capture audio/video error. * * @param error Error returned by the Cordova plugin. * @param defaultMessage Key of the default message to show. * @return Rejected promise. */ protected treatCaptureError(error: CoreCaptureError, defaultMessage: string): CoreError { // Cancelled or error. If cancelled, error is an object with code = 3. if (error) { if (error.code != 3) { // Error, not cancelled. this.logger.error('Error while recording audio/video', error); const message = this.isNoAppError(error) ? Translate.instant('core.fileuploader.errornoapp') : (error.message || Translate.instant(defaultMessage)); throw new CoreError(message); } else { throw new CoreCanceledError(); } } throw new CoreError('Error capturing media'); } /** * Check if a capture error is because there is no app to capture. * * @param error Error. * @return Whether it's because there is no app. */ protected isNoAppError(error: CoreCaptureError): boolean { return error && error.code == 20; } /** * Treat a capture image or browse album error. * * @param error Error returned by the Cordova plugin. * @param defaultMessage Key of the default message to show. * @return Rejected promise. If it doesn't have an error message it means it was cancelled. */ protected treatImageError(error: number | string | CoreError | CoreCaptureError, defaultMessage: string): CoreError { // Cancelled or error. if (!error || typeof error == 'number') { return new CoreError(defaultMessage); } if (typeof error == 'string') { if (error.toLowerCase().indexOf('no image selected') > -1) { // User cancelled. return new CoreCanceledError(); } return new CoreError(error); } else if ('code' in error && error.code == 3) { throw new CoreCanceledError(); } else { throw error; } } /** * Convenient helper for the user to record and upload a video. * * @param isAudio True if uploading an audio, false if it's a video. * @param maxSize Max size of the upload. -1 for no max size. * @param upload True if the file should be uploaded, false to return the picked file. * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. * @return Promise resolved when done. */ async uploadAudioOrVideo( isAudio: boolean, maxSize?: number, upload?: boolean, mimetypes?: string[], ): Promise { this.logger.debug('Trying to record a ' + (isAudio ? 'audio' : 'video') + ' file'); // The mimetypes param is only for browser, the Cordova plugin doesn't support it. const captureOptions = { limit: 1, mimetypes: mimetypes }; let media: MediaFile; try { const medias = isAudio ? await CoreFileUploader.captureAudio(captureOptions) : await CoreFileUploader.captureVideo(captureOptions); media = medias[0]; // We used limit 1, we only want 1 media. } catch (error) { if (isAudio && this.isNoAppError(error) && CoreApp.isMobile()) { // No app to record audio, fallback to capture it ourselves. try { media = await CoreFileUploader.captureAudioInApp(); } catch (error) { throw this.treatCaptureError(error, 'core.fileuploader.errorcapturingaudio'); // Throw the right error. } } else { const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo'; throw this.treatCaptureError(error, defaultError); // Throw the right error. } } let path = media.fullPath; const error = CoreFileUploader.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported. if (error) { throw new Error(error); } // Make sure the path has the protocol. In iOS it doesn't. if (CoreApp.isMobile() && path.indexOf('file://') == -1) { path = 'file://' + path; } const options = CoreFileUploader.getMediaUploadOptions(media); if (upload) { return this.uploadFile(path, maxSize || -1, true, options); } else { // Copy or move the file to our temporary folder. return this.copyToTmpFolder(path, true, maxSize, undefined, options); } } /** * Uploads a file of any type. * This function will not check the size of the file, please check it before calling this function. * * @param uri File URI. * @param name File name. * @param type File type. * @param deleteAfterUpload Whether the file should be deleted after upload. * @param siteId Id of the site to upload the file to. If not defined, use current site. * @return Promise resolved when the file is uploaded. */ uploadGenericFile( uri: string, name: string, type: string, deleteAfterUpload?: boolean, siteId?: string, ): Promise { const options = CoreFileUploader.getFileUploadOptions(uri, name, type, deleteAfterUpload); return this.uploadFile(uri, -1, false, options, siteId); } /** * Convenient helper for the user to upload an image, either from the album or taking it with the camera. * * @param fromAlbum True if the image should be selected from album, false if it should be taken with camera. * @param maxSize Max size of the upload. -1 for no max size. * @param upload True if the file should be uploaded, false to return the picked file. * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. * @return Promise resolved when done. */ async uploadImage( fromAlbum: boolean, maxSize?: number, upload?: boolean, mimetypes?: string[], ): Promise { this.logger.debug('Trying to capture an image with camera'); const options: CameraOptions = { quality: 50, destinationType: Camera.DestinationType.FILE_URI, correctOrientation: true, }; if (fromAlbum) { const imageSupported = !mimetypes || CoreUtils.indexOfRegexp(mimetypes, /^image\//) > -1; const videoSupported = !mimetypes || CoreUtils.indexOfRegexp(mimetypes, /^video\//) > -1; options.sourceType = Camera.PictureSourceType.PHOTOLIBRARY; options.popoverOptions = { x: 10, y: 10, width: Platform.width() - 200, height: Platform.height() - 200, arrowDir: Camera.PopoverArrowDirection.ARROW_ANY, }; // Determine the mediaType based on the mimetypes. if (imageSupported && !videoSupported) { options.mediaType = Camera.MediaType.PICTURE; } else if (!imageSupported && videoSupported) { options.mediaType = Camera.MediaType.VIDEO; } else if (CoreApp.isIOS()) { // Only get all media in iOS because in Android using this option allows uploading any kind of file. options.mediaType = Camera.MediaType.ALLMEDIA; } } else if (mimetypes) { if (mimetypes.indexOf('image/jpeg') > -1) { options.encodingType = Camera.EncodingType.JPEG; } else if (mimetypes.indexOf('image/png') > -1) { options.encodingType = Camera.EncodingType.PNG; } } let path: string | undefined; try { path = await CoreFileUploader.getPicture(options); } catch (error) { const defaultError = fromAlbum ? 'core.fileuploader.errorgettingimagealbum' : 'core.fileuploader.errorcapturingimage'; throw this.treatImageError(error, Translate.instant(defaultError)); } const error = CoreFileUploader.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported. if (error) { throw new CoreError(error); } const uploadOptions = CoreFileUploader.getCameraUploadOptions(path, fromAlbum); if (upload) { return this.uploadFile(path, maxSize || -1, true, uploadOptions); } else { // Copy or move the file to our temporary folder. return this.copyToTmpFolder(path, !fromAlbum, maxSize, 'jpg', uploadOptions); } } /** * Upload a file given the file entry. * * @param fileEntry The file entry. * @param deleteAfter True if the file should be deleted once treated. * @param maxSize Max size of the file. If not defined or -1, no max size. * @param upload True if the file should be uploaded, false to return the picked file. * @param allowOffline True to allow selecting in offline, false to require connection. * @param name Name to use when uploading the file. If not defined, use the file's name. * @return Promise resolved when done. */ async uploadFileEntry( fileEntry: FileEntry, deleteAfter: boolean, maxSize?: number, upload?: boolean, allowOffline?: boolean, name?: string, ): Promise { const file = await CoreFile.getFileObjectFromFileEntry(fileEntry); const result = await this.uploadFileObject(file, maxSize, upload, allowOffline, name); if (deleteAfter) { // We have uploaded and deleted a copy of the file. Now delete the original one. CoreFile.removeFileByFileEntry(fileEntry); } return result; } /** * Upload a file given the file object. * * @param file The file object. * @param maxSize Max size of the file. If not defined or -1, no max size. * @param upload True if the file should be uploaded, false to return the picked file. * @param allowOffline True to allow selecting in offline, false to require connection. * @param name Name to use when uploading the file. If not defined, use the file's name. * @return Promise resolved when done. */ async uploadFileObject( file: IFile | File, maxSize?: number, upload?: boolean, allowOffline?: boolean, name?: string, ): Promise { if (maxSize === 0) { const siteInfo = CoreSites.getCurrentSite()?.getInfo(); if (siteInfo && siteInfo.usermaxuploadfilesize) { maxSize = siteInfo.usermaxuploadfilesize; } } if (maxSize !== undefined && maxSize != -1 && file.size > maxSize) { throw this.createMaxBytesError(maxSize, file.name); } if (upload) { await this.confirmUploadFile(file.size, false, allowOffline); } // 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.copyAndUploadFile(file, upload, name); } /** * Convenience function to upload a file, allowing to retry if it fails. * * @param path Absolute path of the file to upload. * @param maxSize Max size of the upload. -1 for no max size. * @param checkSize True to check size. * @param Options. * @param siteId Site ID. If not defined, current site. * @return Promise resolved if the file is uploaded, rejected otherwise. */ async uploadFile( path: string, maxSize: number, checkSize: boolean, options: CoreFileUploaderOptions, siteId?: string, ): Promise { const errorStr = Translate.instant('core.error'); const retryStr = Translate.instant('core.retry'); const uploadingStr = Translate.instant('core.fileuploader.uploading'); const errorUploading = async (error): Promise => { // Allow the user to retry. try { await CoreDomUtils.showConfirm(error, errorStr, retryStr); } catch (error) { // User cancelled. Delete the file if needed. if (options.deleteAfterUpload) { CoreFile.removeExternalFile(path); } throw new CoreCanceledError(); } // Try again. return this.uploadFile(path, maxSize, checkSize, options, siteId); }; if (!CoreApp.isOnline()) { return errorUploading(Translate.instant('core.fileuploader.errormustbeonlinetoupload')); } let file: IFile | undefined; let size = 0; if (checkSize) { try { // Check that file size is the right one. const fileEntry = await CoreFile.getExternalFile(path); file = await CoreFile.getFileObjectFromFileEntry(fileEntry); size = file.size; } catch (error) { // Ignore failures. } } if (maxSize != -1 && size > maxSize) { throw this.createMaxBytesError(maxSize, file!.name); } if (size > 0) { await this.confirmUploadFile(size); } // File isn't too large and user confirmed, let's upload. const modal = await CoreDomUtils.showModalLoading(uploadingStr); try { return await CoreFileUploader.uploadFile( path, options, (progress: ProgressEvent) => { this.showProgressModal(modal, 'core.fileuploader.uploadingperc', progress); }, siteId, ); } catch (error) { this.logger.error('Error uploading file.', error); modal.dismiss(); return errorUploading(error); } finally { modal.dismiss(); } } /** * Show a progress modal. * * @param modal The modal where to show the progress. * @param stringKey The key of the string to display. * @param progress The progress event. */ protected showProgressModal( modal: CoreIonLoadingElement, stringKey: string, progress: ProgressEvent | CoreFileProgressEvent, ): void { if (!progress || !progress.lengthComputable) { return; } // Calculate the progress percentage. const perc = Math.min((progress.loaded! / progress.total!) * 100, 100); if (isNaN(perc) || perc < 0) { return; } modal.updateText(Translate.instant(stringKey, { $a: perc.toFixed(1) })); } } export const CoreFileUploaderHelper = makeSingleton(CoreFileUploaderHelperProvider);