diff --git a/src/app/classes/site.ts b/src/app/classes/site.ts index 539b3259d..9e875a91d 100644 --- a/src/app/classes/site.ts +++ b/src/app/classes/site.ts @@ -19,7 +19,14 @@ import { CoreApp } from '@services/app'; import { CoreDB } from '@services/db'; import { CoreEvents } from '@singletons/events'; import { CoreFile } from '@services/file'; -import { CoreWS, CoreWSPreSets, CoreWSFileUploadOptions, CoreWSAjaxPreSets, CoreWSExternalWarning } from '@services/ws'; +import { + CoreWS, + CoreWSPreSets, + CoreWSFileUploadOptions, + CoreWSAjaxPreSets, + CoreWSExternalWarning, + CoreWSUploadFileResult, +} from '@services/ws'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; @@ -1070,16 +1077,16 @@ export class CoreSite { * @param onProgress Function to call on progress. * @return Promise resolved when uploaded. */ - uploadFile( + uploadFile( filePath: string, options: CoreWSFileUploadOptions, onProgress?: (event: ProgressEvent) => void, - ): Promise { + ): Promise { if (!options.fileArea) { options.fileArea = 'draft'; } - return CoreWS.instance.uploadFile(filePath, options, { + return CoreWS.instance.uploadFile(filePath, options, { siteUrl: this.siteUrl, wsToken: this.token || '', }, onProgress); diff --git a/src/app/core/fileuploader/fileuploader-init.module.ts b/src/app/core/fileuploader/fileuploader-init.module.ts new file mode 100644 index 000000000..12f6f533d --- /dev/null +++ b/src/app/core/fileuploader/fileuploader-init.module.ts @@ -0,0 +1,53 @@ +// (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 { NgModule } from '@angular/core'; + +import { CoreFileUploaderDelegate } from './services/fileuploader.delegate'; +import { CoreFileUploaderAlbumHandler } from './services/handlers/album'; +import { CoreFileUploaderAudioHandler } from './services/handlers/audio'; +import { CoreFileUploaderCameraHandler } from './services/handlers/camera'; +import { CoreFileUploaderFileHandler } from './services/handlers/file'; +import { CoreFileUploaderVideoHandler } from './services/handlers/video'; + + +@NgModule({ + imports: [], + declarations: [], + providers: [ + CoreFileUploaderAlbumHandler, + CoreFileUploaderAudioHandler, + CoreFileUploaderCameraHandler, + CoreFileUploaderFileHandler, + CoreFileUploaderVideoHandler, + ], +}) +export class CoreFileUploaderInitModule { + + constructor( + delegate: CoreFileUploaderDelegate, + albumHandler: CoreFileUploaderAlbumHandler, + audioHandler: CoreFileUploaderAudioHandler, + cameraHandler: CoreFileUploaderCameraHandler, + videoHandler: CoreFileUploaderVideoHandler, + fileHandler: CoreFileUploaderFileHandler, + ) { + delegate.registerHandler(albumHandler); + delegate.registerHandler(audioHandler); + delegate.registerHandler(cameraHandler); + delegate.registerHandler(videoHandler); + delegate.registerHandler(fileHandler); + } + +} diff --git a/src/app/core/fileuploader/lang/en.json b/src/app/core/fileuploader/lang/en.json new file mode 100644 index 000000000..22d14df4a --- /dev/null +++ b/src/app/core/fileuploader/lang/en.json @@ -0,0 +1,29 @@ +{ + "addfiletext": "Add file", + "audio": "Audio", + "camera": "Camera", + "confirmuploadfile": "You are about to upload {{size}}. Are you sure you want to continue?", + "confirmuploadunknownsize": "It was not possible to calculate the size of the upload. Are you sure you want to continue?", + "errorcapturingaudio": "Error capturing audio.", + "errorcapturingimage": "Error capturing image.", + "errorcapturingvideo": "Error capturing video.", + "errorgettingimagealbum": "Error getting image from album.", + "errormustbeonlinetoupload": "You have to be online to upload files.", + "errornoapp": "You don't have an app installed to perform this action.", + "errorreadingfile": "Error reading file.", + "errorwhileuploading": "An error occurred during the file upload.", + "file": "File", + "fileuploaded": "The file was successfully uploaded.", + "filesofthesetypes": "Accepted file types:", + "invalidfiletype": "{{$a}} filetype cannot be accepted.", + "maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.", + "more": "More", + "photoalbums": "Photo albums", + "readingfile": "Reading file", + "readingfileperc": "Reading file: {{$a}}%", + "selectafile": "Select a file", + "uploadafile": "Upload a file", + "uploading": "Uploading", + "uploadingperc": "Uploading: {{$a}}%", + "video": "Video" +} \ No newline at end of file diff --git a/src/app/core/fileuploader/services/fileuploader.delegate.ts b/src/app/core/fileuploader/services/fileuploader.delegate.ts new file mode 100644 index 000000000..8fbb9852d --- /dev/null +++ b/src/app/core/fileuploader/services/fileuploader.delegate.ts @@ -0,0 +1,198 @@ +// (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 { FileEntry } from '@ionic-native/file'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreEvents } from '@singletons/events'; +import { CoreWSUploadFileResult } from '@services/ws'; + +/** + * Interface that all handlers must implement. + */ +export interface CoreFileUploaderHandler extends CoreDelegateHandler { + /** + * Handler's priority. The highest priority, the highest position. + */ + priority?: number; + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param mimetypes List of mimetypes. + * @return Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]): string[]; + + /** + * Get the data to display the handler. + * + * @return Data. + */ + getData(): CoreFileUploaderHandlerData; +} + +/** + * Data needed to render the handler in the file picker. It must be returned by the handler. + */ +export interface CoreFileUploaderHandlerData { + /** + * The title to display in the handler. + */ + title: string; + + /** + * The icon to display in the handler. + */ + icon?: string; + + /** + * The class to assign to the handler item. + */ + class?: string; + + /** + * Action to perform when the handler is clicked. + * + * @param maxSize Max size of the file. If not defined or -1, no max size. + * @param upload Whether the file should be uploaded. + * @param allowOffline True to allow selecting in offline, false to require connection. + * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. + * @return Promise resolved with the result of picking/uploading the file. + */ + action?( + maxSize?: number, + upload?: boolean, + allowOffline?: boolean, + mimetypes?: string[], + ): Promise; + + /** + * Function called after the handler is rendered. + * + * @param maxSize Max size of the file. If not defined or -1, no max size. + * @param upload Whether the file should be uploaded. + * @param allowOffline True to allow selecting in offline, false to require connection. + * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. + */ + afterRender?(maxSize?: number, upload?: boolean, allowOffline?: boolean, mimetypes?: string[]): void; +} + +/** + * The result of clicking a handler. + */ +export interface CoreFileUploaderHandlerResult { + /** + * Whether the file was treated (uploaded or copied to tmp folder). + */ + treated: boolean; + + /** + * The path of the file picked. Required if treated=false and fileEntry is not set. + */ + path?: string; + + /** + * The fileEntry of the file picked. Required if treated=false and path is not set. + */ + fileEntry?: FileEntry; + + /** + * Whether the file should be deleted after the upload. Ignored if treated=true. + */ + delete?: boolean; + + /** + * The result of picking/uploading the file. Ignored if treated=false. + */ + result?: CoreWSUploadFileResult | FileEntry; +} + +/** + * Data returned by the delegate for each handler. + */ +export interface CoreFileUploaderHandlerDataToReturn extends CoreFileUploaderHandlerData { + /** + * Handler's priority. + */ + priority?: number; + + /** + * Supported mimetypes. + */ + mimetypes?: string[]; +} + +/** + * Delegate to register handlers to be shown in the file picker. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreFileUploaderDelegate extends CoreDelegate { + + constructor() { + super('CoreFileUploaderDelegate', true); + + CoreEvents.on(CoreEvents.LOGOUT, this.clearSiteHandlers.bind(this)); + } + + /** + * Clear current site handlers. Reserved for core use. + */ + protected clearSiteHandlers(): void { + this.enabledHandlers = {}; + } + + /** + * Get the handlers for the current site. + * + * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. + * @return List of handlers data. + */ + getHandlers(mimetypes?: string[]): CoreFileUploaderHandlerDataToReturn[] { + const handlers: CoreFileUploaderHandlerDataToReturn[] = []; + + for (const name in this.enabledHandlers) { + const handler = this.enabledHandlers[name]; + let supportedMimetypes: string[] | undefined; + + if (mimetypes) { + if (!handler.getSupportedMimetypes) { + // Handler doesn't implement a required function, don't add it. + continue; + } + + supportedMimetypes = handler.getSupportedMimetypes(mimetypes); + + if (!supportedMimetypes.length) { + // Handler doesn't support any mimetype, don't add it. + continue; + } + } + + const data: CoreFileUploaderHandlerDataToReturn = handler.getData(); + data.priority = handler.priority; + data.mimetypes = supportedMimetypes; + handlers.push(data); + } + + // Sort them by priority. + handlers.sort((a, b) => (a.priority || 0) <= (b.priority || 0) ? 1 : -1); + + return handlers; + } + +} diff --git a/src/app/core/fileuploader/services/fileuploader.helper.ts b/src/app/core/fileuploader/services/fileuploader.helper.ts new file mode 100644 index 000000000..9aa8c431a --- /dev/null +++ b/src/app/core/fileuploader/services/fileuploader.helper.ts @@ -0,0 +1,875 @@ +// (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, PromiseDefer } from '@services/utils/utils'; +import { makeSingleton, Translate, Camera, Chooser, Platform, ActionSheetController } from '@singletons/core.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 '@/app/classes/errors/captureerror'; +import { CoreIonLoadingElement } from '@/app/classes/ion-loading'; +import { CoreWSUploadFileResult } from '@/app/services/ws'; + +/** + * Helper service to upload files. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreFileUploaderHelperProvider { + + protected logger: CoreLogger; + protected filePickerDeferred?: PromiseDefer; + protected actionSheet?: HTMLIonActionSheetElement; + + constructor(protected uploaderDelegate: CoreFileUploaderDelegate) { + 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.instance.showModalLoading(); + + const result = await Chooser.instance.getFile(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.instance.isInvalidMimetype(mimetypes, result.name, result.mediaType); + + if (error) { + throw new CoreError(error); + } + + const options = CoreFileUploader.instance.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.instance.isOnline()) { + throw new CoreError(Translate.instance.instant('core.fileuploader.errormustbeonlinetoupload')); + } + + wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreFileUploaderProvider.WIFI_SIZE_WARNING : wifiThreshold; + limitedThreshold = typeof limitedThreshold == 'undefined' ? + CoreFileUploaderProvider.LIMITED_SIZE_WARNING : limitedThreshold; + + if (size < 0) { + return CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.fileuploader.confirmuploadunknownsize')); + } else if (size >= wifiThreshold || (CoreApp.instance.isNetworkAccessLimited() && size >= limitedThreshold)) { + const readableSize = CoreTextUtils.instance.bytesToSize(size, 2); + + return CoreDomUtils.instance.showConfirm( + Translate.instance.instant('core.fileuploader.confirmuploadfile', { size: readableSize }), + ); + } else if (alwaysConfirm) { + return CoreDomUtils.instance.showConfirm(Translate.instance.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.instance.showModalLoading('core.fileuploader.readingfile', true); + let fileEntry: FileEntry | undefined; + + try { + // Get unique name for the copy. + const newName = await CoreFile.instance.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, name); + + const filePath = CoreTextUtils.instance.concatenatePaths(CoreFileProvider.TMPFOLDER, newName); + + // Write the data into the file. + fileEntry = await CoreFile.instance.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.instance.getFileAndDirectoryFromPath(path).name; + + // Check that size isn't too large. + if (typeof maxSize != 'undefined' && maxSize != -1) { + try { + const fileEntry = await CoreFile.instance.getExternalFile(path); + + const fileData = await CoreFile.instance.getFileObjectFromFileEntry(fileEntry); + + if (fileData.size > maxSize) { + throw this.createMaxBytesError(maxSize, fileEntry.name); + } + } catch (error) { + // Ignore failures. + } + } + + // File isn't too large. + // Get a unique name in the folder to prevent overriding another file. + const newName = await CoreFile.instance.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, fileName, defaultExt); + + // Now move or copy the file. + const destPath = CoreTextUtils.instance.concatenatePaths(CoreFileProvider.TMPFOLDER, newName); + if (shouldDelete) { + return CoreFile.instance.moveExternalFile(path, destPath); + } else { + return CoreFile.instance.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.instance.instant('core.fileuploader.maxbytesfile', { + $a: { + file: fileName, + size: CoreTextUtils.instance.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.instance.getFileAndDirectoryFromPath(result.uri); + + if (!nameAndDir.name) { + return; + } + + let extension = CoreMimetypeUtils.instance.getFileExtension(nameAndDir.name); + + if (!extension) { + // The URI doesn't have an extension, add it now. + extension = CoreMimetypeUtils.instance.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.instance.instant('core.cancel'), + role: 'cancel', + handler: (): void => { + // User cancelled the action sheet. + this.filePickerClosed(); + }, + }]; + const handlers = this.uploaderDelegate.getHandlers(mimetypes); + + this.filePickerDeferred = CoreUtils.instance.promiseDefer(); + + // Create a button for each handler. + handlers.forEach((handler) => { + buttons.push({ + text: Translate.instance.instant(handler.title), + icon: handler.icon, + cssClass: handler.class, + handler: async (): Promise => { + if (!handler.action) { + // Nothing to do. + return false; + } + + if (!allowOffline && !CoreApp.instance.isOnline()) { + // Not allowed, show error. + CoreDomUtils.instance.showErrorModal('core.fileuploader.errormustbeonlinetoupload', true); + + return false; + } + + try { + const data = await handler.action(maxSize, upload, allowOffline, handler.mimetypes); + + if (data.treated) { + // The handler already treated the file. Return the result. + this.fileUploaded(data.result!); + + return true; + } else if (data.fileEntry) { + // The handler provided us a fileEntry, use it. + await this.uploadFileEntry(data.fileEntry, !!data.delete, maxSize, upload, allowOffline); + + return true; + } 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.instance.getFile(data.path); + } catch (error) { + // File not found, it's probably an absolute path. + fileEntry = await CoreFile.instance.getExternalFile(data.path); + } + + // File found, treat it. + await this.uploadFileEntry(fileEntry, !!data.delete, maxSize, upload, allowOffline); + + return true; + } + + // Nothing received, fail. + throw new CoreError('No file received'); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault( + error, + Translate.instance.instant('core.fileuploader.errorreadingfile'), + ); + + return false; + } + }, + }); + }); + + this.actionSheet = await ActionSheetController.instance.create({ + header: title ? title : Translate.instance.instant('core.fileuploader.' + (upload ? 'uploadafile' : 'selectafile')), + buttons: buttons, + }); + 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.promise; + } + + /** + * 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.instance.getFileObjectFromFileEntry(fileEntry); + + await this.confirmUploadFile(file.size); + + await this.uploadGenericFile(fileEntry.toURL(), file.name, file.type, deleteAfterUpload, siteId); + + CoreDomUtils.instance.showToast('core.fileuploader.fileuploaded', true, undefined, 'core-toast-success'); + } catch (error) { + CoreDomUtils.instance.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.instance.instant('core.fileuploader.errornoapp') : + (error.message || Translate.instance.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: string | CoreError | CoreCaptureError, defaultMessage: string): CoreError { + // Cancelled or error. + if (!error) { + 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.instance.captureAudio(captureOptions) : + await CoreFileUploader.instance.captureVideo(captureOptions); + + media = medias[0]; // We used limit 1, we only want 1 media. + } catch (error) { + + if (isAudio && this.isNoAppError(error) && CoreApp.instance.isMobile() && + (!Platform.instance.is('android') || CoreApp.instance.getPlatformMajorVersion() < 10)) { + // No app to record audio, fallback to capture it ourselves. + // In Android it will only be done in Android 9 or lower because there's a bug in the plugin. + try { + media = await CoreFileUploader.instance.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.instance.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.instance.isMobile() && path.indexOf('file://') == -1) { + path = 'file://' + path; + } + + const options = CoreFileUploader.instance.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.instance.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.instance.DestinationType.FILE_URI, + correctOrientation: true, + }; + + if (fromAlbum) { + const imageSupported = !mimetypes || CoreUtils.instance.indexOfRegexp(mimetypes, /^image\//) > -1; + const videoSupported = !mimetypes || CoreUtils.instance.indexOfRegexp(mimetypes, /^video\//) > -1; + + options.sourceType = Camera.instance.PictureSourceType.PHOTOLIBRARY; + options.popoverOptions = { + x: 10, + y: 10, + width: Platform.instance.width() - 200, + height: Platform.instance.height() - 200, + arrowDir: Camera.instance.PopoverArrowDirection.ARROW_ANY, + }; + + // Determine the mediaType based on the mimetypes. + if (imageSupported && !videoSupported) { + options.mediaType = Camera.instance.MediaType.PICTURE; + } else if (!imageSupported && videoSupported) { + options.mediaType = Camera.instance.MediaType.VIDEO; + } else if (CoreApp.instance.isIOS()) { + // Only get all media in iOS because in Android using this option allows uploading any kind of file. + options.mediaType = Camera.instance.MediaType.ALLMEDIA; + } + } else if (mimetypes) { + if (mimetypes.indexOf('image/jpeg') > -1) { + options.encodingType = Camera.instance.EncodingType.JPEG; + } else if (mimetypes.indexOf('image/png') > -1) { + options.encodingType = Camera.instance.EncodingType.PNG; + } + } + + let path: string | undefined; + + try { + path = await CoreFileUploader.instance.getPicture(options); + } catch (error) { + const defaultError = fromAlbum ? 'core.fileuploader.errorgettingimagealbum' : 'core.fileuploader.errorcapturingimage'; + + throw this.treatImageError(error, defaultError); + } + + const error = CoreFileUploader.instance.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported. + if (error) { + throw new CoreError(error); + } + + const uploadOptions = CoreFileUploader.instance.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.instance.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.instance.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 !== 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.instance.instant('core.error'); + const retryStr = Translate.instance.instant('core.retry'); + const uploadingStr = Translate.instance.instant('core.fileuploader.uploading'); + const errorUploading = async (error): Promise => { + // Allow the user to retry. + try { + await CoreDomUtils.instance.showConfirm(error, errorStr, retryStr); + } catch (error) { + // User cancelled. Delete the file if needed. + if (options.deleteAfterUpload) { + CoreFile.instance.removeExternalFile(path); + } + + throw new CoreCanceledError(); + } + + // Try again. + return this.uploadFile(path, maxSize, checkSize, options, siteId); + }; + + if (!CoreApp.instance.isOnline()) { + return errorUploading(Translate.instance.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.instance.getExternalFile(path); + + file = await CoreFile.instance.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.instance.showModalLoading(uploadingStr); + + try { + return await CoreFileUploader.instance.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; + } + + const contentElement = modal.loading?.querySelector('.loading-content'); + if (contentElement) { + contentElement.innerHTML = Translate.instance.instant(stringKey, { $a: perc.toFixed(1) }); + } + } + +} + +export class CoreFileUploaderHelper extends makeSingleton(CoreFileUploaderHelperProvider) {} diff --git a/src/app/core/fileuploader/services/fileuploader.ts b/src/app/core/fileuploader/services/fileuploader.ts new file mode 100644 index 000000000..38eda6f6f --- /dev/null +++ b/src/app/core/fileuploader/services/fileuploader.ts @@ -0,0 +1,664 @@ +// (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 { CameraOptions } from '@ionic-native/camera/ngx'; +import { FileEntry } from '@ionic-native/file/ngx'; +import { MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture/ngx'; +import { Subject } from 'rxjs'; + +import { CoreApp } from '@services/app'; +import { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile, CoreWSFileUploadOptions, CoreWSUploadFileResult } from '@services/ws'; +import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreEmulatorCaptureMediaComponent } from '@core/emulator/components/capture-media/capture-media'; +import { CoreError } from '@/app/classes/errors/error'; + +/** + * 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({ + providedIn: 'root', +}) +export class CoreFileUploaderProvider { + + static readonly LIMITED_SIZE_WARNING = 1048576; // 1 MB. + static readonly WIFI_SIZE_WARNING = 10485760; // 10 MB. + + protected logger: CoreLogger; + + // 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() { + this.logger = CoreLogger.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: (CoreWSExternalFile | FileEntry)[], b: (CoreWSExternalFile | FileEntry)[]): 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 (CoreFile.instance.getFileName(a[i]) != CoreFile.instance.getFileName(b[i])) { + 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. + */ + async captureAudio(options: CaptureAudioOptions): Promise { + this.onAudioCapture.next(true); + + try { + return await MediaCapture.instance.captureAudio(options); + } finally { + this.onAudioCapture.next(false); + } + } + + /** + * Record an audio file without using an external app. + * + * @return Promise resolved with the file. + */ + async captureAudioInApp(): Promise { + const params = { + type: 'audio', + }; + + const modal = await ModalController.instance.create({ + component: CoreEmulatorCaptureMediaComponent, + cssClass: 'core-modal-fullscreen', + componentProps: params, + backdropDismiss: false, + }); + + modal.present(); + + const result = await modal.onWillDismiss(); + + if (result.role == 'success') { + return result.data[0]; + } else { + throw result.data; + } + } + + /** + * Start the video recorder application and return information about captured video clip files. + * + * @param options Options. + * @return Promise resolved with the result. + */ + async captureVideo(options: CaptureVideoOptions): Promise { + this.onVideoCapture.next(true); + + try { + return await MediaCapture.instance.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: (CoreWSExternalFile | FileEntry)[]): void { + // Delete the local files. + files.forEach((file) => { + if ('remove' in file) { + // 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 = CoreMimetypeUtils.instance.guessExtensionFromUrl(uri); + const mimetype = CoreMimetypeUtils.instance.getMimeType(extension); + const isIOS = CoreApp.instance.isIOS(); + const options: CoreFileUploaderOptions = { + deleteAfterUpload: !isFromAlbum, + mimeType: mimetype, + }; + const fileName = CoreFile.instance.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] += '_' + CoreTimeUtils.instance.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 = CoreFile.instance.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 mimetype File mimetype. + * @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, + mimetype?: string, + deleteAfterUpload?: boolean, + fileArea?: string, + itemId?: number, + ): CoreFileUploaderOptions { + const options: CoreFileUploaderOptions = {}; + options.fileName = name; + options.mimeType = mimetype || CoreMimetypeUtils.instance.getMimeType( + CoreMimetypeUtils.instance.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] += '_' + CoreTimeUtils.instance.readableTimestamp(); + filename = split.join('.'); + } + + options.fileName = filename; + options.deleteAfterUpload = true; + if (mediaFile.type) { + options.mimeType = mediaFile.type; + } else { + options.mimeType = CoreMimetypeUtils.instance.getMimeType( + CoreMimetypeUtils.instance.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 Camera.instance.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. + */ + async getStoredFiles(folderPath: string): Promise { + return await CoreFile.instance.getDirectoryContents(folderPath); + } + + /** + * 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. + */ + async getStoredFilesFromOfflineFilesObject( + filesObject: CoreFileUploaderStoreFilesResult, + folderPath: string, + ): Promise<(CoreWSExternalFile | FileEntry)[]> { + let files: (CoreWSExternalFile | FileEntry)[] = []; + + if (filesObject.online.length > 0) { + files = CoreUtils.instance.clone(filesObject.online); + } + + if (filesObject.offline > 0) { + const offlineFiles = await CoreUtils.instance.ignoreErrors(this.getStoredFiles(folderPath)); + + if (offlineFiles) { + files = files.concat(offlineFiles); + } + } + + return 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 | undefined { + let extension: string | undefined; + + if (mimetypes) { + // Verify that the mimetype of the file is supported. + if (mimetype) { + extension = CoreMimetypeUtils.instance.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 = CoreMimetypeUtils.instance.getMimeType(extension); + } + } else if (path) { + extension = CoreMimetypeUtils.instance.getFileExtension(path); + mimetype = CoreMimetypeUtils.instance.getMimeType(extension); + } else { + throw new CoreError('No mimetype or path supplied.'); + } + + if (mimetype && mimetypes.indexOf(mimetype) == -1) { + extension = extension || Translate.instance.instant('core.unknown'); + + return Translate.instance.instant('core.fileuploader.invalidfiletype', { $a: extension }); + } + } + } + + /** + * Mark files as offline. + * + * @param files Files to mark as offline. + * @return Files marked as offline. + * @deprecated since 3.9.5. Now stored files no longer have an offline property. + */ + markOfflineFiles(files: FileEntry[]): FileEntry[] { + 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): CoreFileUploaderTypeList | undefined { + filetypeList = filetypeList?.trim(); + + if (!filetypeList || filetypeList == '*') { + // All types supported, return undefined. + return; + } + + const filetypes = filetypeList.split(/[;, ]+/g); + const mimetypes: Record = {}; // Use an object to prevent duplicates. + const typesInfo: CoreFileUploaderTypeListInfoEntry[] = []; + + filetypes.forEach((filetype) => { + filetype = filetype.trim(); + + if (!filetype) { + return; + } + + if (filetype.indexOf('/') != -1) { + // It's a mimetype. + typesInfo.push({ + name: CoreMimetypeUtils.instance.getMimetypeDescription(filetype), + extlist: CoreMimetypeUtils.instance.getExtensions(filetype).map(this.addDot).join(' '), + }); + + mimetypes[filetype] = true; + } else if (filetype.indexOf('.') === 0) { + // It's an extension. + const mimetype = CoreMimetypeUtils.instance.getMimeType(filetype); + typesInfo.push({ + name: mimetype && CoreMimetypeUtils.instance.getMimetypeDescription(mimetype), + extlist: filetype, + }); + + if (mimetype) { + mimetypes[mimetype] = true; + } + } else { + // It's a group. + const groupExtensions = CoreMimetypeUtils.instance.getGroupMimeInfo(filetype, 'extensions'); + const groupMimetypes = CoreMimetypeUtils.instance.getGroupMimeInfo(filetype, 'mimetypes'); + + if (groupExtensions && groupExtensions.length > 0) { + typesInfo.push({ + name: CoreMimetypeUtils.instance.getTranslatedGroupName(filetype), + extlist: groupExtensions.map(this.addDot).join(' '), + }); + + groupMimetypes?.forEach((mimetype) => { + if (mimetype) { + mimetypes[mimetype] = true; + } + }); + } else { + // Treat them as extensions. + filetype = this.addDot(filetype); + + const mimetype = CoreMimetypeUtils.instance.getMimeType(filetype); + typesInfo.push({ + name: mimetype && CoreMimetypeUtils.instance.getMimetypeDescription(mimetype), + 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. + */ + async storeFilesToUpload( + folderPath: string, + files: (CoreWSExternalFile | FileEntry)[], + ): Promise { + const result: CoreFileUploaderStoreFilesResult = { + online: [], + offline: 0, + }; + + if (!files || !files.length) { + return result; + } + + // Remove unused files from previous saves. + await CoreFile.instance.removeUnusedFiles(folderPath, files); + + await Promise.all(files.map(async (file) => { + if (!CoreUtils.instance.isFileEntry(file)) { + // 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.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 = CoreTextUtils.instance.concatenatePaths(folderPath, file.name); + result.offline++; + + await CoreFile.instance.copyFile(file.toURL(), destFile); + } + })); + + 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. + */ + async uploadFile( + uri: string, + options?: CoreFileUploaderOptions, + onProgress?: (event: ProgressEvent) => void, + siteId?: string, + ): Promise { + options = options || {}; + + const deleteAfterUpload = options.deleteAfterUpload; + const ftOptions = CoreUtils.instance.clone(options); + + delete ftOptions.deleteAfterUpload; + + const site = await CoreSites.instance.getSite(siteId); + + const result = await site.uploadFile(uri, ftOptions, onProgress); + + if (deleteAfterUpload) { + CoreFile.instance.removeExternalFile(uri); + } + + return result; + } + + /** + * 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. + */ + async uploadOrReuploadFile( + file: CoreWSExternalFile | FileEntry, + itemId?: number, + component?: string, + componentId?: string | number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + let fileName: string | undefined; + let fileEntry: FileEntry | undefined; + + const isOnline = !CoreUtils.instance.isFileEntry(file); + + if (CoreUtils.instance.isFileEntry(file)) { + // Local file, we already have the file entry. + fileName = file.name; + fileEntry = file; + } else { + // It's an online file. We need to download it and re-upload it. + fileName = file.filename; + + const path = await CoreFilepool.instance.downloadUrl( + siteId, + file.fileurl, + false, + component, + componentId, + file.timemodified, + undefined, + undefined, + file, + ); + + fileEntry = await CoreFile.instance.getExternalFile(path); + } + + // Now upload the file. + const extension = CoreMimetypeUtils.instance.getFileExtension(fileName!); + const mimetype = extension ? CoreMimetypeUtils.instance.getMimeType(extension) : undefined; + const options = this.getFileUploadOptions(fileEntry.toURL(), fileName!, mimetype, isOnline, 'draft', itemId); + + const result = await this.uploadFile(fileEntry.toURL(), options, undefined, siteId); + + 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. + */ + async uploadOrReuploadFiles( + files: (CoreWSExternalFile | FileEntry)[], + component?: string, + componentId?: string | number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!files || !files.length) { + // Return fake draft ID. + return 1; + } + + // Upload only the first file first to get a draft id. + const itemId = await this.uploadOrReuploadFile(files[0], 0, component, componentId, siteId); + + const promises: Promise[] = []; + + for (let i = 1; i < files.length; i++) { + const file = files[i]; + promises.push(this.uploadOrReuploadFile(file, itemId, component, componentId, siteId)); + } + + await Promise.all(promises); + + return itemId; + } + +} + +export class CoreFileUploader extends makeSingleton(CoreFileUploaderProvider) {} + +export type CoreFileUploaderStoreFilesResult = { + online: CoreWSExternalFile[]; // List of online files. + offline: number; // Number of offline files. +}; + +export type CoreFileUploaderTypeList = { + info: CoreFileUploaderTypeListInfoEntry[]; + mimetypes: string[]; +}; + +export type CoreFileUploaderTypeListInfoEntry = { + name?: string; + extlist: string; +}; diff --git a/src/app/core/fileuploader/services/handlers/album.ts b/src/app/core/fileuploader/services/handlers/album.ts new file mode 100644 index 000000000..8e5762bd0 --- /dev/null +++ b/src/app/core/fileuploader/services/handlers/album.ts @@ -0,0 +1,77 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult } from '../fileuploader.delegate'; +import { CoreFileUploaderHelper } from '../fileuploader.helper'; + +/** + * Handler to upload files from the album. + */ +@Injectable() +export class CoreFileUploaderAlbumHandler implements CoreFileUploaderHandler { + + name = 'CoreFileUploaderAlbum'; + priority = 2000; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return CoreApp.instance.isMobile(); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param mimetypes List of mimetypes. + * @return Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]): string[] { + // Album allows picking images and videos. + return CoreUtils.instance.filterByRegexp(mimetypes, /^(image|video)\//); + } + + /** + * Get the data to display the handler. + * + * @return Data. + */ + getData(): CoreFileUploaderHandlerData { + return { + title: 'core.fileuploader.photoalbums', + class: 'core-fileuploader-album-handler', + icon: 'images', + action: async ( + maxSize?: number, + upload?: boolean, + allowOffline?: boolean, + mimetypes?: string[], + ): Promise => { + const result = await CoreFileUploaderHelper.instance.uploadImage(true, maxSize, upload, mimetypes); + + return { + treated: true, + result: result, + }; + }, + }; + } + +} diff --git a/src/app/core/fileuploader/services/handlers/audio.ts b/src/app/core/fileuploader/services/handlers/audio.ts new file mode 100644 index 000000000..b1f68202e --- /dev/null +++ b/src/app/core/fileuploader/services/handlers/audio.ts @@ -0,0 +1,92 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult } from '../fileuploader.delegate'; +import { CoreFileUploaderHelper } from '../fileuploader.helper'; +/** + * Handler to record an audio to upload it. + */ +@Injectable() +export class CoreFileUploaderAudioHandler implements CoreFileUploaderHandler { + + name = 'CoreFileUploaderAudio'; + priority = 1600; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return CoreApp.instance.isMobile() || (CoreApp.instance.canGetUserMedia() && CoreApp.instance.canRecordMedia()); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param mimetypes List of mimetypes. + * @return Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]): string[] { + if (CoreApp.instance.isIOS()) { + // In iOS it's recorded as WAV. + return CoreUtils.instance.filterByRegexp(mimetypes, /^audio\/wav$/); + } else if (CoreApp.instance.isAndroid()) { + // In Android we don't know the format the audio will be recorded, so accept any audio mimetype. + return CoreUtils.instance.filterByRegexp(mimetypes, /^audio\//); + } else { + // In browser, support audio formats that are supported by MediaRecorder. + if (MediaRecorder) { + return mimetypes.filter((type) => { + const matches = type.match(/^audio\//); + + return matches && matches.length && MediaRecorder.isTypeSupported(type); + }); + } + } + + return []; + } + + /** + * Get the data to display the handler. + * + * @return Data. + */ + getData(): CoreFileUploaderHandlerData { + return { + title: 'core.fileuploader.audio', + class: 'core-fileuploader-audio-handler', + icon: 'mic', + action: async ( + maxSize?: number, + upload?: boolean, + allowOffline?: boolean, + mimetypes?: string[], + ): Promise => { + const result = await CoreFileUploaderHelper.instance.uploadAudioOrVideo(true, maxSize, upload, mimetypes); + + return { + treated: true, + result: result, + }; + }, + }; + } + +} diff --git a/src/app/core/fileuploader/services/handlers/camera.ts b/src/app/core/fileuploader/services/handlers/camera.ts new file mode 100644 index 000000000..74d6021f8 --- /dev/null +++ b/src/app/core/fileuploader/services/handlers/camera.ts @@ -0,0 +1,77 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult } from '../fileuploader.delegate'; +import { CoreFileUploaderHelper } from '../fileuploader.helper'; + +/** + * Handler to take a picture to upload it. + */ +@Injectable() +export class CoreFileUploaderCameraHandler implements CoreFileUploaderHandler { + + name = 'CoreFileUploaderCamera'; + priority = 1800; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return CoreApp.instance.isMobile() || CoreApp.instance.canGetUserMedia(); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param mimetypes List of mimetypes. + * @return Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]): string[] { + // Camera only supports JPEG and PNG. + return CoreUtils.instance.filterByRegexp(mimetypes, /^image\/(jpeg|png)$/); + } + + /** + * Get the data to display the handler. + * + * @return Data. + */ + getData(): CoreFileUploaderHandlerData { + return { + title: 'core.fileuploader.camera', + class: 'core-fileuploader-camera-handler', + icon: 'camera', + action: async ( + maxSize?: number, + upload?: boolean, + allowOffline?: boolean, + mimetypes?: string[], + ): Promise => { + const result = await CoreFileUploaderHelper.instance.uploadImage(false, maxSize, upload, mimetypes); + + return { + treated: true, + result: result, + }; + }, + }; + } + +} diff --git a/src/app/core/fileuploader/services/handlers/file.ts b/src/app/core/fileuploader/services/handlers/file.ts new file mode 100644 index 000000000..eaf592b77 --- /dev/null +++ b/src/app/core/fileuploader/services/handlers/file.ts @@ -0,0 +1,158 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult } from '../fileuploader.delegate'; +import { CoreFileUploaderHelper } from '../fileuploader.helper'; +import { CoreFileUploader } from '../fileuploader'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Handler to upload any type of file. + */ +@Injectable() +export class CoreFileUploaderFileHandler implements CoreFileUploaderHandler { + + name = 'CoreFileUploaderFile'; + priority = 1200; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param mimetypes List of mimetypes. + * @return Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]): string[] { + return mimetypes; + } + + /** + * Get the data to display the handler. + * + * @return Data. + */ + getData(): CoreFileUploaderHandlerData { + const handler: CoreFileUploaderHandlerData = { + title: 'core.fileuploader.file', + class: 'core-fileuploader-file-handler', + icon: 'folder', + }; + + if (CoreApp.instance.isMobile()) { + handler.action = async ( + maxSize?: number, + upload?: boolean, + allowOffline?: boolean, + mimetypes?: string[], + ): Promise => { + const result = await CoreFileUploaderHelper.instance.chooseAndUploadFile(maxSize, upload, allowOffline, mimetypes); + + return { + treated: true, + result: result, + }; + }; + + } else { + handler.afterRender = ( + maxSize?: number, + upload?: boolean, + allowOffline?: boolean, + mimetypes?: string[], + ): void => { + // Add an invisible file input in the file handler. + // It needs to be done like this because the action sheet items don't accept inputs. + const element = document.querySelector('.core-fileuploader-file-handler'); + if (!element) { + return; + } + + const input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.classList.add('core-fileuploader-file-handler-input'); + if (mimetypes && mimetypes.length && (!CoreApp.instance.isAndroid() || mimetypes.length == 1)) { + // Don't use accept attribute in Android with several mimetypes, it's not supported. + input.setAttribute('accept', mimetypes.join(', ')); + } + + input.addEventListener('change', async () => { + const file = input.files?.[0]; + + input.value = ''; // Unset input. + if (!file) { + return; + } + + // Verify that the mimetype of the file is supported, in case the accept attribute isn't supported. + const error = CoreFileUploader.instance.isInvalidMimetype(mimetypes, file.name, file.type); + if (error) { + CoreDomUtils.instance.showErrorModal(error); + + return; + } + + try { + // Upload the picked file. + const result = await CoreFileUploaderHelper.instance.uploadFileObject( + file, + maxSize, + upload, + allowOffline, + file.name, + ); + + CoreFileUploaderHelper.instance.fileUploaded(result); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault( + error, + Translate.instance.instant('core.fileuploader.errorreadingfile'), + ); + } + }); + + if (CoreApp.instance.isIOS()) { + // In iOS, the click on the input stopped working for some reason. We need to put it 1 level higher. + element.parentElement?.appendChild(input); + + // Animate the button when the input is clicked. + input.addEventListener('mousedown', () => { + element.classList.add('activated'); + }); + input.addEventListener('mouseup', () => { + setTimeout(() => { + element.classList.remove('activated'); + }, 80); + }); + } else { + element.appendChild(input); + } + }; + } + + return handler; + } + +} diff --git a/src/app/core/fileuploader/services/handlers/video.ts b/src/app/core/fileuploader/services/handlers/video.ts new file mode 100644 index 000000000..96224fef5 --- /dev/null +++ b/src/app/core/fileuploader/services/handlers/video.ts @@ -0,0 +1,92 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult } from '../fileuploader.delegate'; +import { CoreFileUploaderHelper } from '../fileuploader.helper'; +/** + * Handler to record a video to upload it. + */ +@Injectable() +export class CoreFileUploaderVideoHandler implements CoreFileUploaderHandler { + + name = 'CoreFileUploaderVideo'; + priority = 1400; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return CoreApp.instance.isMobile() || (CoreApp.instance.canGetUserMedia() && CoreApp.instance.canRecordMedia()); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param mimetypes List of mimetypes. + * @return Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]): string[] { + if (CoreApp.instance.isIOS()) { + // In iOS it's recorded as MOV. + return CoreUtils.instance.filterByRegexp(mimetypes, /^video\/quicktime$/); + } else if (CoreApp.instance.isAndroid()) { + // In Android we don't know the format the video will be recorded, so accept any video mimetype. + return CoreUtils.instance.filterByRegexp(mimetypes, /^video\//); + } else { + // In browser, support video formats that are supported by MediaRecorder. + if (MediaRecorder) { + return mimetypes.filter((type) => { + const matches = type.match(/^video\//); + + return matches?.length && MediaRecorder.isTypeSupported(type); + }); + } + } + + return []; + } + + /** + * Get the data to display the handler. + * + * @return Data. + */ + getData(): CoreFileUploaderHandlerData { + return { + title: 'core.fileuploader.video', + class: 'core-fileuploader-video-handler', + icon: 'videocam', + action: async ( + maxSize?: number, + upload?: boolean, + allowOffline?: boolean, + mimetypes?: string[], + ): Promise => { + const result = await CoreFileUploaderHelper.instance.uploadAudioOrVideo(false, maxSize, upload, mimetypes); + + return { + treated: true, + result: result, + }; + }, + }; + } + +} diff --git a/src/app/services/app.ts b/src/app/services/app.ts index a8efc544e..c977b166f 100644 --- a/src/app/services/app.ts +++ b/src/app/services/app.ts @@ -23,7 +23,7 @@ import { CoreUrlUtils } from '@services/utils/url'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { CoreConstants } from '@core/constants'; -import { makeSingleton, Keyboard, Network, StatusBar, Platform } from '@singletons/core.singletons'; +import { makeSingleton, Keyboard, Network, StatusBar, Platform, Device } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; import { DBNAME, SCHEMA_VERSIONS_TABLE_NAME, SCHEMA_VERSIONS_TABLE_SCHEMA, SchemaVersionsDBEntry } from '@services/app.db'; @@ -240,6 +240,17 @@ export class CoreAppProvider { return storesConfig.default; } + /** + * Get platform major version number. + */ + getPlatformMajorVersion(): number { + if (!this.isMobile()) { + return 0; + } + + return Number(Device.instance.version?.split('.')[0]); + } + /** * Checks if the app is running in a 64 bits desktop environment (not browser). * diff --git a/src/app/services/file.ts b/src/app/services/file.ts index 7ac755faf..0912615fb 100644 --- a/src/app/services/file.ts +++ b/src/app/services/file.ts @@ -1239,6 +1239,16 @@ export class CoreFileProvider { return !path || !path.match(/^[a-z0-9]+:\/\//i) || path.indexOf(this.basePath) != -1; } + /** + * Get the file's name. + * + * @param file The file. + * @return The file name. + */ + getFileName(file: CoreWSExternalFile | FileEntry): string | undefined { + return CoreUtils.instance.isFileEntry(file) ? file.name : file.filename; + } + } export class CoreFile extends makeSingleton(CoreFileProvider) {} diff --git a/src/app/services/local-notifications.ts b/src/app/services/local-notifications.ts index def7f011c..55253e69c 100644 --- a/src/app/services/local-notifications.ts +++ b/src/app/services/local-notifications.ts @@ -26,7 +26,7 @@ import { CoreSite } from '@classes/site'; import { CoreQueueRunner } from '@classes/queue-runner'; import { CoreError } from '@classes/errors/error'; import { CoreConstants } from '@core/constants'; -import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push, Device } from '@singletons/core.singletons'; +import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; import { APP_SCHEMA, @@ -173,7 +173,7 @@ export class CoreLocalNotificationsProvider { */ canDisableSound(): boolean { // Only allow disabling sound in Android 7 or lower. In iOS and Android 8+ it can easily be done with system settings. - return this.isAvailable() && CoreApp.instance.isAndroid() && Number(Device.instance.version?.split('.')[0]) < 8; + return this.isAvailable() && CoreApp.instance.isAndroid() && CoreApp.instance.getPlatformMajorVersion() < 8; } /** diff --git a/src/app/services/utils/mimetype.ts b/src/app/services/utils/mimetype.ts index a82301b50..bbf1219c4 100644 --- a/src/app/services/utils/mimetype.ts +++ b/src/app/services/utils/mimetype.ts @@ -361,7 +361,9 @@ export class CoreMimetypeUtilsProvider { * @param field The field to get. If not supplied, all the info will be returned. * @return Info for the group. */ - getGroupMimeInfo(group: string, field?: string): MimeTypeGroupInfo { + getGroupMimeInfo(group: string): MimeTypeGroupInfo; + getGroupMimeInfo(group: string, field: string): string[] | undefined; + getGroupMimeInfo(group: string, field?: string): MimeTypeGroupInfo | string[] | undefined { if (typeof this.groupsMimeInfo[group] == 'undefined') { this.fillGroupMimeInfo(group); } @@ -379,7 +381,11 @@ export class CoreMimetypeUtilsProvider { * @param extension Extension. * @return Mimetype. */ - getMimeType(extension: string): string | undefined { + getMimeType(extension?: string): string | undefined { + if (!extension) { + return; + } + extension = this.cleanExtension(extension); if (this.extToMime[extension] && this.extToMime[extension].type) { diff --git a/src/app/services/ws.ts b/src/app/services/ws.ts index 77c5dd89e..bcacb74c2 100644 --- a/src/app/services/ws.ts +++ b/src/app/services/ws.ts @@ -737,12 +737,12 @@ export class CoreWSProvider { * @param onProgress Function to call on progress. * @return Promise resolved when uploaded. */ - async uploadFile( + async uploadFile( filePath: string, options: CoreWSFileUploadOptions, preSets: CoreWSPreSets, onProgress?: (event: ProgressEvent) => void, - ): Promise { + ): Promise { this.logger.debug(`Trying to upload file: ${filePath}`); if (!filePath || !options || !preSets) { @@ -1193,3 +1193,16 @@ export type CoreWSDownloadedFileEntry = FileEntry & { extension: string; // File extension. path: string; // File path. }; + +export type CoreWSUploadFileResult = { + component: string; // Component the file was uploaded to. + context: string; // Context the file was uploaded to. + userid: number; // User that uploaded the file. + filearea: string; // File area the file was uploaded to. + filename: string; // File name. + filepath: string; // File path. + itemid: number; // Item ID the file was uploaded to. + license: string; // File license. + author: string; // Author name. + source: string; // File source. +};