// (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 { ModalController } from 'ionic-angular'; import { Camera, CameraOptions } from '@ionic-native/camera'; import { FileEntry } from '@ionic-native/file'; import { MediaCapture, MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture'; import { TranslateService } from '@ngx-translate/core'; import { CoreFileProvider } from '@providers/file'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreWSFileUploadOptions, CoreWSExternalFile } from '@providers/ws'; import { Subject } from 'rxjs'; import { CoreApp } from '@providers/app'; import { makeSingleton } from '@singletons/core.singletons'; /** * File upload options. */ export interface CoreFileUploaderOptions extends CoreWSFileUploadOptions { /** * Whether the file should be deleted after the upload (if success). */ deleteAfterUpload?: boolean; } /** * Service to upload files. */ @Injectable() export class CoreFileUploaderProvider { static LIMITED_SIZE_WARNING = 1048576; // 1 MB. static WIFI_SIZE_WARNING = 10485760; // 10 MB. protected logger; // Observers to notify when a media file starts/stops being recorded/selected. onGetPicture: Subject = new Subject(); onAudioCapture: Subject = new Subject(); onVideoCapture: Subject = new Subject(); constructor(logger: CoreLoggerProvider, protected fileProvider: CoreFileProvider, protected textUtils: CoreTextUtilsProvider, protected utils: CoreUtilsProvider, protected sitesProvider: CoreSitesProvider, protected timeUtils: CoreTimeUtilsProvider, protected mimeUtils: CoreMimetypeUtilsProvider, protected filepoolProvider: CoreFilepoolProvider, protected translate: TranslateService, protected mediaCapture: MediaCapture, protected camera: Camera, protected modalCtrl: ModalController) { this.logger = logger.getInstance('CoreFileUploaderProvider'); } /** * Add a dot to the beginning of an extension. * * @param extension Extension. * @return Treated extension. */ protected addDot(extension: string): string { return '.' + extension; } /** * Compares two file lists and returns if they are different. * * @param a First file list. * @param b Second file list. * @return Whether both lists are different. */ areFileListDifferent(a: any[], b: any[]): boolean { a = a || []; b = b || []; if (a.length != b.length) { return true; } // Currently we are going to compare the order of the files as well. // This function can be improved comparing more fields or not comparing the order. for (let i = 0; i < a.length; i++) { if (a[i].name != b[i].name || a[i].filename != b[i].filename) { return true; } } return false; } /** * Start the audio recorder application and return information about captured audio clip files. * * @param options Options. * @return Promise resolved with the result. */ captureAudio(options: CaptureAudioOptions): Promise { this.onAudioCapture.next(true); return this.mediaCapture.captureAudio(options).finally(() => { this.onAudioCapture.next(false); }); } /** * Record an audio file without using an external app. * * @return Promise resolved with the file. */ captureAudioInApp(): Promise { return new Promise((resolve, reject): any => { const params = { type: 'audio', }; const modal = this.modalCtrl.create('CoreEmulatorCaptureMediaPage', params, { enableBackdropDismiss: false }); modal.present(); modal.onDidDismiss((data: any, role: string) => { if (role == 'success') { resolve(data[0]); } else { reject(data); } }); }); } /** * Start the video recorder application and return information about captured video clip files. * * @param options Options. * @return Promise resolved with the result. */ captureVideo(options: CaptureVideoOptions): Promise { this.onVideoCapture.next(true); return this.mediaCapture.captureVideo(options).finally(() => { this.onVideoCapture.next(false); }); } /** * Clear temporary attachments to be uploaded. * Attachments already saved in an offline store will NOT be deleted. * * @param files List of files. */ clearTmpFiles(files: any[]): void { // Delete the local files. files.forEach((file) => { if (!file.offline && file.remove) { // Pass an empty function to prevent missing parameter error. file.remove(() => { // Nothing to do. }); } }); } /** * Get the upload options for a file taken with the Camera Cordova plugin. * * @param uri File URI. * @param isFromAlbum True if the image was taken from album, false if it's a new image taken with camera. * @return Options. */ getCameraUploadOptions(uri: string, isFromAlbum?: boolean): CoreFileUploaderOptions { const extension = this.mimeUtils.guessExtensionFromUrl(uri); const mimetype = this.mimeUtils.getMimeType(extension); const isIOS = CoreApp.instance.isIOS(); const options: CoreFileUploaderOptions = { deleteAfterUpload: !isFromAlbum, mimeType: mimetype }; const fileName = this.fileProvider.getFileAndDirectoryFromPath(uri).name; if (isIOS && (mimetype == 'image/jpeg' || mimetype == 'image/png')) { // In iOS, the pictures can have repeated names, even if they come from the album. // Add a timestamp to the filename to make it unique. const split = fileName.split('.'); split[0] += '_' + this.timeUtils.readableTimestamp(); options.fileName = split.join('.'); } else { // Use the same name that the file already has. options.fileName = fileName; } if (isFromAlbum) { // If the file was picked from the album, delete it only if it was copied to the app's folder. options.deleteAfterUpload = this.fileProvider.isFileInAppFolder(uri); if (CoreApp.instance.isAndroid()) { // Picking an image from album in Android adds a timestamp at the end of the file. Delete it. options.fileName = options.fileName.replace(/(\.[^\.]*)\?[^\.]*$/, '$1'); } } return options; } /** * Get the upload options for a file of any type. * * @param uri File URI. * @param name File name. * @param type File type. * @param deleteAfterUpload Whether the file should be deleted after upload. * @param fileArea File area to upload the file to. It defaults to 'draft'. * @param itemId Draft ID to upload the file to, 0 to create new. * @return Options. */ getFileUploadOptions(uri: string, name: string, type: string, deleteAfterUpload?: boolean, fileArea?: string, itemId?: number) : CoreFileUploaderOptions { const options: CoreFileUploaderOptions = {}; options.fileName = name; options.mimeType = type || this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(options.fileName)); options.deleteAfterUpload = !!deleteAfterUpload; options.itemId = itemId || 0; options.fileArea = fileArea; return options; } /** * Get the upload options for a file taken with the media capture Cordova plugin. * * @param mediaFile File object to upload. * @return Options. */ getMediaUploadOptions(mediaFile: MediaFile): CoreFileUploaderOptions { const options: CoreFileUploaderOptions = {}; let filename = mediaFile.name; if (!filename.match(/_\d{14}(\..*)?$/)) { // Add a timestamp to the filename to make it unique. const split = filename.split('.'); split[0] += '_' + this.timeUtils.readableTimestamp(); filename = split.join('.'); } options.fileName = filename; options.deleteAfterUpload = true; if (mediaFile.type) { options.mimeType = mediaFile.type; } else { options.mimeType = this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(options.fileName)); } return options; } /** * Take a picture or video, or load one from the library. * * @param options Options. * @return Promise resolved with the result. */ getPicture(options: CameraOptions): Promise { this.onGetPicture.next(true); return this.camera.getPicture(options).finally(() => { this.onGetPicture.next(false); }); } /** * Get the files stored in a folder, marking them as offline. * * @param folderPath Folder where to get the files. * @return Promise resolved with the list of files. */ getStoredFiles(folderPath: string): Promise { return this.fileProvider.getDirectoryContents(folderPath).then((files) => { return this.markOfflineFiles(files); }); } /** * Get stored files from combined online and offline file object. * * @param filesObject The combined offline and online files object. * @param folderPath Folder path to get files from. * @return Promise resolved with files. */ getStoredFilesFromOfflineFilesObject(filesObject: { online: any[], offline: number }, folderPath: string): Promise { let files = []; if (filesObject) { if (filesObject.online && filesObject.online.length > 0) { files = this.utils.clone(filesObject.online); } if (filesObject.offline > 0) { return this.getStoredFiles(folderPath).then((offlineFiles) => { return files.concat(offlineFiles); }).catch(() => { // Ignore not found files. return files; }); } } return Promise.resolve(files); } /** * Check if a file's mimetype is invalid based on the list of accepted mimetypes. This function needs either the file's * mimetype or the file's path/name. * * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. * @param path File's path or name. * @param mimetype File's mimetype. * @return Undefined if file is valid, error message if file is invalid. */ isInvalidMimetype(mimetypes?: string[], path?: string, mimetype?: string): string { let extension: string; if (mimetypes) { // Verify that the mimetype of the file is supported. if (mimetype) { extension = this.mimeUtils.getExtension(mimetype); if (mimetypes.indexOf(mimetype) == -1) { // Get the "main" mimetype of the extension. // It's possible that the list of accepted mimetypes only includes the "main" mimetypes. mimetype = this.mimeUtils.getMimeType(extension); } } else { extension = this.mimeUtils.getFileExtension(path); mimetype = this.mimeUtils.getMimeType(extension); } if (mimetype && mimetypes.indexOf(mimetype) == -1) { extension = extension || this.translate.instant('core.unknown'); return this.translate.instant('core.fileuploader.invalidfiletype', { $a: extension }); } } } /** * Mark files as offline. * * @param files Files to mark as offline. * @return Files marked as offline. */ markOfflineFiles(files: any[]): any[] { // Mark the files as pending offline. files.forEach((file) => { file.offline = true; file.filename = file.name; }); return files; } /** * Parse filetypeList to get the list of allowed mimetypes and the data to render information. * * @param filetypeList Formatted string list where the mimetypes can be checked. * @return Mimetypes and the filetypes informations. Undefined if all types supported. */ prepareFiletypeList(filetypeList: string): { info: any[], mimetypes: string[] } { filetypeList = filetypeList && filetypeList.trim(); if (!filetypeList || filetypeList == '*') { // All types supported, return undefined. return undefined; } const filetypes = filetypeList.split(/[;, ]+/g), mimetypes = {}, // Use an object to prevent duplicates. typesInfo = []; filetypes.forEach((filetype) => { filetype = filetype.trim(); if (filetype) { if (filetype.indexOf('/') != -1) { // It's a mimetype. typesInfo.push({ name: this.mimeUtils.getMimetypeDescription(filetype), extlist: this.mimeUtils.getExtensions(filetype).map(this.addDot).join(' ') }); mimetypes[filetype] = true; } else if (filetype.indexOf('.') === 0) { // It's an extension. const mimetype = this.mimeUtils.getMimeType(filetype); typesInfo.push({ name: mimetype ? this.mimeUtils.getMimetypeDescription(mimetype) : false, extlist: filetype }); if (mimetype) { mimetypes[mimetype] = true; } } else { // It's a group. const groupExtensions = this.mimeUtils.getGroupMimeInfo(filetype, 'extensions'), groupMimetypes = this.mimeUtils.getGroupMimeInfo(filetype, 'mimetypes'); if (groupExtensions.length > 0) { typesInfo.push({ name: this.mimeUtils.getTranslatedGroupName(filetype), extlist: groupExtensions ? groupExtensions.map(this.addDot).join(' ') : '' }); groupMimetypes.forEach((mimetype) => { if (mimetype) { mimetypes[mimetype] = true; } }); } else { // Treat them as extensions. filetype = this.addDot(filetype); const mimetype = this.mimeUtils.getMimeType(filetype); typesInfo.push({ name: mimetype ? this.mimeUtils.getMimetypeDescription(mimetype) : false, extlist: filetype }); if (mimetype) { mimetypes[mimetype] = true; } } } } }); return { info: typesInfo, mimetypes: Object.keys(mimetypes) }; } /** * Given a list of files (either online files or local files), store the local files in a local folder * to be uploaded later. * * @param folderPath Path of the folder where to store the files. * @param files List of files. * @return Promise resolved if success. */ storeFilesToUpload(folderPath: string, files: any[]): Promise<{ online: any[], offline: number }> { const result = { online: [], offline: 0 }; if (!files || !files.length) { return Promise.resolve(result); } // Remove unused files from previous saves. return this.fileProvider.removeUnusedFiles(folderPath, files).then(() => { const promises = []; files.forEach((file) => { if (file.filename && !file.name) { // It's an online file, add it to the result and ignore it. result.online.push({ filename: file.filename, fileurl: file.fileurl }); } else if (!file.name) { // Error. promises.push(Promise.reject(null)); } else if (file.fullPath && file.fullPath.indexOf(folderPath) != -1) { // File already in the submission folder. result.offline++; } else { // Local file, copy it. // Use copy instead of move to prevent having a unstable state if some copies succeed and others don't. const destFile = this.textUtils.concatenatePaths(folderPath, file.name); promises.push(this.fileProvider.copyFile(file.toURL(), destFile)); result.offline++; } }); return Promise.all(promises).then(() => { return result; }); }); } /** * Upload a file. * * @param uri File URI. * @param options Options for the upload. * @param onProgress Function to call on progress. * @param siteId Id of the site to upload the file to. If not defined, use current site. * @return Promise resolved when done. */ uploadFile(uri: string, options?: CoreFileUploaderOptions, onProgress?: (event: ProgressEvent) => any, siteId?: string): Promise { options = options || {}; const deleteAfterUpload = options.deleteAfterUpload, ftOptions = this.utils.clone(options); delete ftOptions.deleteAfterUpload; return this.sitesProvider.getSite(siteId).then((site) => { return site.uploadFile(uri, ftOptions, onProgress); }).then((result) => { if (deleteAfterUpload) { setTimeout(() => { // Use set timeout, otherwise in Electron the upload threw an error sometimes. this.fileProvider.removeExternalFile(uri); }, 500); } return result; }); } /** * Given a list of files (either online files or local files), upload the local files to the draft area. * Local files are not deleted from the device after upload. * * @param itemId Draft ID. * @param files List of files. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the itemId. */ async uploadFiles(itemId: number, files: (CoreWSExternalFile | FileEntry)[], siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); if (!files || !files.length) { return; } await Promise.all(files.map(async (file) => { if (( file).filename && !( file).name) { // File already uploaded, ignore it. return; } file = file; // Now upload the file. const options = this.getFileUploadOptions(file.toURL(), file.name, undefined, false, 'draft', itemId); await this.uploadFile(file.toURL(), options, undefined, siteId); })); } /** * Upload a file to a draft area and return the draft ID. * * If the file is an online file it will be downloaded and then re-uploaded. * If the file is a local file it will not be deleted from the device after upload. * * @param file Online file or local FileEntry. * @param itemId Draft ID to use. Undefined or 0 to create a new draft ID. * @param component The component to set to the downloaded files. * @param componentId An ID to use in conjunction with the component. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the itemId. */ uploadOrReuploadFile(file: any, itemId?: number, component?: string, componentId?: string | number, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); let promise, fileName; const isOnline = file.filename && !file.name; if (isOnline) { // It's an online file. We need to download it and re-upload it. fileName = file.filename; promise = this.filepoolProvider.downloadUrl(siteId, file.url || file.fileurl, false, component, componentId, file.timemodified, undefined, undefined, file).then((path) => { return this.fileProvider.getExternalFile(path); }); } else { // Local file, we already have the file entry. fileName = file.name; promise = Promise.resolve(file); } return promise.then((fileEntry) => { // Now upload the file. const options = this.getFileUploadOptions(fileEntry.toURL(), fileName, fileEntry.type, isOnline, 'draft', itemId); return this.uploadFile(fileEntry.toURL(), options, undefined, siteId).then((result) => { return result.itemid; }); }); } /** * Given a list of files (either online files or local files), upload them to a draft area and return the draft ID. * * Online files will be downloaded and then re-uploaded. * Local files are not deleted from the device after upload. * If there are no files to upload it will return a fake draft ID (1). * * @param files List of files. * @param component The component to set to the downloaded files. * @param componentId An ID to use in conjunction with the component. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the itemId. */ uploadOrReuploadFiles(files: any[], component?: string, componentId?: string | number, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); if (!files || !files.length) { // Return fake draft ID. return Promise.resolve(1); } // Upload only the first file first to get a draft id. return this.uploadOrReuploadFile(files[0], 0, component, componentId, siteId).then((itemId) => { const promises = []; for (let i = 1; i < files.length; i++) { const file = files[i]; promises.push(this.uploadOrReuploadFile(file, itemId, component, componentId, siteId)); } return Promise.all(promises).then(() => { return itemId; }); }); } } export class CoreFileUploader extends makeSingleton(CoreFileUploaderProvider) {}