784 lines
27 KiB
TypeScript

// (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, CoreFileProvider } from '@services/file';
import { CoreFilepool } from '@services/filepool';
import { CoreSites } from '@services/sites';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSFile, CoreWSFileUploadOptions, CoreWSUploadFileResult } from '@services/ws';
import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from '@singletons';
import { CoreLogger } from '@singletons/logger';
import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media';
import { CoreError } from '@classes/errors/error';
import { CoreSite } from '@classes/site';
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';
import { CoreText } from '@singletons/text';
/**
* 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<boolean> = new Subject<boolean>();
onAudioCapture: Subject<boolean> = new Subject<boolean>();
onVideoCapture: Subject<boolean> = new Subject<boolean>();
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: CoreFileEntry[], b: CoreFileEntry[]): 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.getFileName(a[i]) != CoreFile.getFileName(b[i])) {
return true;
}
}
return false;
}
/**
* Check if a certain site allows deleting draft files.
*
* @param siteId Site Id. If not defined, use current site.
* @return Promise resolved with true if can delete.
* @since 3.10
*/
async canDeleteDraftFiles(siteId?: string): Promise<boolean> {
try {
const site = await CoreSites.getSite(siteId);
return this.canDeleteDraftFilesInSite(site);
} catch (error) {
return false;
}
}
/**
* Check if a certain site allows deleting draft files.
*
* @param site Site. If not defined, use current site.
* @return Whether draft files can be deleted.
* @since 3.10
*/
canDeleteDraftFilesInSite(site?: CoreSite): boolean {
site = site || CoreSites.getCurrentSite();
return !!(site?.wsAvailable('core_files_delete_draft_files'));
}
/**
* 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<MediaFile[] | CaptureError> {
this.onAudioCapture.next(true);
try {
return await MediaCapture.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<MediaFile> {
const params = {
type: 'audio',
};
const modal = await ModalController.create({
component: CoreEmulatorCaptureMediaComponent,
cssClass: 'core-modal-fullscreen',
componentProps: params,
backdropDismiss: false,
});
await 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<MediaFile[] | CaptureError> {
this.onVideoCapture.next(true);
try {
return await 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, only files in tmp folder will be deleted.
*
* @param files List of files.
*/
clearTmpFiles(files: (CoreWSFile | FileEntry)[]): void {
// Delete the temporary files.
files.forEach((file) => {
if ('remove' in file && CoreFile.removeBasePath(file.toURL()).startsWith(CoreFileProvider.TMPFOLDER)) {
// Pass an empty function to prevent missing parameter error.
file.remove(() => {
// Nothing to do.
});
}
});
}
/**
* Delete draft files.
*
* @param draftId Draft ID.
* @param files Files to delete.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteDraftFiles(draftId: number, files: { filepath: string; filename: string }[], siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
const params = {
draftitemid: draftId,
files: files,
};
return site.write('core_files_delete_draft_files', params);
}
/**
* 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.guessExtensionFromUrl(uri);
const mimetype = CoreMimetypeUtils.getMimeType(extension);
const isIOS = CoreApp.isIOS();
const options: CoreFileUploaderOptions = {
deleteAfterUpload: !isFromAlbum,
mimeType: mimetype,
};
const fileName = CoreFile.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.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.isFileInAppFolder(uri);
if (CoreApp.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;
}
/**
* Given a list of original files and a list of current files, return the list of files to delete.
*
* @param originalFiles Original files.
* @param currentFiles Current files.
* @return List of files to delete.
*/
getFilesToDelete(
originalFiles: CoreWSFile[],
currentFiles: CoreFileEntry[],
): { filepath: string; filename: string }[] {
const filesToDelete: { filepath: string; filename: string }[] = [];
currentFiles = currentFiles || [];
originalFiles.forEach((file) => {
const stillInList = currentFiles.some((currentFile) =>
CoreFileHelper.getFileUrl(<CoreWSFile> currentFile) == CoreFileHelper.getFileUrl(file));
if (!stillInList) {
filesToDelete.push({
filepath: file.filepath!,
filename: file.filename!,
});
}
});
return filesToDelete;
}
/**
* 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.getMimeType(
CoreMimetypeUtils.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.readableTimestamp();
filename = split.join('.');
}
options.fileName = filename;
options.deleteAfterUpload = true;
if (mediaFile.type) {
options.mimeType = mediaFile.type;
} else {
options.mimeType = CoreMimetypeUtils.getMimeType(
CoreMimetypeUtils.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<string> {
this.onGetPicture.next(true);
return 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.
*/
async getStoredFiles(folderPath: string): Promise<FileEntry[]> {
return <FileEntry[]> await CoreFile.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<CoreFileEntry[]> {
let files: CoreFileEntry[] = [];
if (filesObject.online.length > 0) {
files = CoreUtils.clone(filesObject.online);
}
if (filesObject.offline > 0) {
const offlineFiles = await CoreUtils.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.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.getMimeType(extension);
}
} else if (path) {
extension = CoreMimetypeUtils.getFileExtension(path);
mimetype = CoreMimetypeUtils.getMimeType(extension);
} else {
throw new CoreError('No mimetype or path supplied.');
}
if (mimetype && mimetypes.indexOf(mimetype) == -1) {
extension = extension || Translate.instant('core.unknown');
return Translate.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<string, boolean> = {}; // 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.getMimetypeDescription(filetype),
extlist: CoreMimetypeUtils.getExtensions(filetype).map(this.addDot).join(' '),
});
mimetypes[filetype] = true;
} else if (filetype.indexOf('.') === 0) {
// It's an extension.
const mimetype = CoreMimetypeUtils.getMimeType(filetype);
typesInfo.push({
name: mimetype && CoreMimetypeUtils.getMimetypeDescription(mimetype),
extlist: filetype,
});
if (mimetype) {
mimetypes[mimetype] = true;
}
} else {
// It's a group.
const groupExtensions = CoreMimetypeUtils.getGroupMimeInfo(filetype, 'extensions');
const groupMimetypes = CoreMimetypeUtils.getGroupMimeInfo(filetype, 'mimetypes');
if (groupExtensions && groupExtensions.length > 0) {
typesInfo.push({
name: CoreMimetypeUtils.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.getMimeType(filetype);
typesInfo.push({
name: mimetype && CoreMimetypeUtils.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: CoreFileEntry[],
): Promise<CoreFileUploaderStoreFilesResult> {
const result: CoreFileUploaderStoreFilesResult = {
online: [],
offline: 0,
};
if (!files || !files.length) {
return result;
}
// Remove unused files from previous saves.
await CoreFile.removeUnusedFiles(folderPath, files);
await Promise.all(files.map(async (file) => {
if (!CoreUtils.isFileEntry(file)) {
// It's an online file, add it to the result and ignore it.
result.online.push({
filename: file.filename,
fileurl: CoreFileHelper.getFileUrl(file),
});
} 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 = CoreText.concatenatePaths(folderPath, file.name);
result.offline++;
await CoreFile.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<CoreWSUploadFileResult> {
options = options || {};
const deleteAfterUpload = options.deleteAfterUpload;
const ftOptions = CoreUtils.clone(options);
delete ftOptions.deleteAfterUpload;
const site = await CoreSites.getSite(siteId);
const result = await site.uploadFile(uri, ftOptions, onProgress);
if (deleteAfterUpload) {
CoreFile.removeExternalFile(uri);
}
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: CoreFileEntry[], siteId?: string): Promise<void> {
siteId = siteId || CoreSites.getCurrentSiteId();
if (!files || !files.length) {
return;
}
// Index the online files by name.
const usedNames: {[name: string]: CoreFileEntry} = {};
const filesToUpload: FileEntry[] = [];
files.forEach((file) => {
if (CoreUtils.isFileEntry(file)) {
filesToUpload.push(<FileEntry> file);
} else {
// It's an online file.
usedNames[file.filename!.toLowerCase()] = file;
}
});
await Promise.all(filesToUpload.map(async (file) => {
// Make sure the file name is unique in the area.
const name = CoreFile.calculateUniqueName(usedNames, file.name);
usedNames[name] = file;
// Now upload the file.
const options = this.getFileUploadOptions(file.toURL(), 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.
*/
async uploadOrReuploadFile(
file: CoreFileEntry,
itemId?: number,
component?: string,
componentId?: string | number,
siteId?: string,
): Promise<number> {
siteId = siteId || CoreSites.getCurrentSiteId();
let fileName: string | undefined;
let fileEntry: FileEntry | undefined;
const isOnline = !CoreUtils.isFileEntry(file);
if (CoreUtils.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.downloadUrl(
siteId,
CoreFileHelper.getFileUrl(file),
false,
component,
componentId,
file.timemodified,
undefined,
undefined,
file,
);
fileEntry = await CoreFile.getExternalFile(path);
}
// Now upload the file.
const extension = CoreMimetypeUtils.getFileExtension(fileName!);
const mimetype = extension ? CoreMimetypeUtils.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: CoreFileEntry[],
component?: string,
componentId?: string | number,
siteId?: string,
): Promise<number> {
siteId = siteId || CoreSites.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<number>[] = [];
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 const CoreFileUploader = makeSingleton(CoreFileUploaderProvider);
export type CoreFileUploaderStoreFilesResult = {
online: CoreWSFile[]; // List of online files.
offline: number; // Number of offline files.
};
export type CoreFileUploaderTypeList = {
info: CoreFileUploaderTypeListInfoEntry[];
mimetypes: string[];
};
export type CoreFileUploaderTypeListInfoEntry = {
name?: string;
extlist: string;
};