From 4bb7f0e97f4e0f3e68271b958c51e043bd62dce3 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 6 Nov 2020 15:20:28 +0100 Subject: [PATCH 1/9] MOBILE-3585 emulator: Add mocks of media services --- package-lock.json | 60 ++ package.json | 4 + src/app/classes/errors/captureerror.ts | 30 + .../capture-media/capture-media.html | 66 ++ .../capture-media/capture-media.scss | 71 +++ .../components/capture-media/capture-media.ts | 588 ++++++++++++++++++ .../emulator/components/components.module.ts | 41 ++ src/app/core/emulator/emulator.module.ts | 26 + src/app/core/emulator/services/camera.ts | 47 ++ .../core/emulator/services/capture.helper.ts | 218 +++++++ .../core/emulator/services/media-capture.ts | 62 ++ 11 files changed, 1213 insertions(+) create mode 100644 src/app/classes/errors/captureerror.ts create mode 100644 src/app/core/emulator/components/capture-media/capture-media.html create mode 100644 src/app/core/emulator/components/capture-media/capture-media.scss create mode 100644 src/app/core/emulator/components/capture-media/capture-media.ts create mode 100644 src/app/core/emulator/components/components.module.ts create mode 100644 src/app/core/emulator/services/camera.ts create mode 100644 src/app/core/emulator/services/capture.helper.ts create mode 100644 src/app/core/emulator/services/media-capture.ts diff --git a/package-lock.json b/package-lock.json index 6f81cf9a7..8183fe042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2115,6 +2115,36 @@ } } }, + "@ionic-native/camera": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@ionic-native/camera/-/camera-5.29.0.tgz", + "integrity": "sha512-JOmFb2eWeh8zZWu2JlNVRbhcSvOcwiTSdoabEfGtw0ITXs0FzuRmzAQgF2PQGyPA8844wkr3T5IUhcMpYxW6UQ==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, + "@ionic-native/chooser": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@ionic-native/chooser/-/chooser-5.29.0.tgz", + "integrity": "sha512-1/+zr+SbijWqd0FomOh83aQb8vqH2qO2CAlgX2FyjJuK4fgt3BF9GMXpzTjkd/qrHO9rbxUMFAcrQAv/HAVNiA==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, "@ionic-native/clipboard": { "version": "5.28.0", "resolved": "https://registry.npmjs.org/@ionic-native/clipboard/-/clipboard-5.28.0.tgz", @@ -2247,6 +2277,36 @@ "@types/cordova": "^0.0.34" } }, + "@ionic-native/media": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@ionic-native/media/-/media-5.29.0.tgz", + "integrity": "sha512-XC8MtrbeR0X0I6B0FABStc2mSAmgIQidaRjFqP4jBAElAwjZC7PHwaDyyVJUOR1Rx5Nest46hZAU6jpAPZ8+pw==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, + "@ionic-native/media-capture": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.29.0.tgz", + "integrity": "sha512-5NdTXQGbrpXLeeLbI+cGQaeNmpmOrPC9vgX4jvUT6whUdDXGZ93wLT1/eeRj208czNiqbdetjG8Dji3OJZ5MKA==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, "@ionic-native/network": { "version": "5.28.0", "resolved": "https://registry.npmjs.org/@ionic-native/network/-/network-5.28.0.tgz", diff --git a/package.json b/package.json index c16199081..22bee0290 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "@angular/platform-browser": "~10.0.0", "@angular/platform-browser-dynamic": "~10.0.0", "@angular/router": "~10.0.0", + "@ionic-native/camera": "^5.29.0", + "@ionic-native/chooser": "^5.29.0", "@ionic-native/clipboard": "^5.28.0", "@ionic-native/core": "^5.0.0", "@ionic-native/device": "^5.28.0", @@ -51,6 +53,8 @@ "@ionic-native/ionic-webview": "^5.28.0", "@ionic-native/keyboard": "^5.28.0", "@ionic-native/local-notifications": "^5.28.0", + "@ionic-native/media": "^5.29.0", + "@ionic-native/media-capture": "^5.29.0", "@ionic-native/network": "^5.28.0", "@ionic-native/push": "^5.28.0", "@ionic-native/qr-scanner": "^5.28.0", diff --git a/src/app/classes/errors/captureerror.ts b/src/app/classes/errors/captureerror.ts new file mode 100644 index 000000000..302ffb5a8 --- /dev/null +++ b/src/app/classes/errors/captureerror.ts @@ -0,0 +1,30 @@ +// (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 { CoreError } from './error'; + +/** + * Capture error. + */ +export class CoreCaptureError extends CoreError { + + code: number; + + constructor(code: number, message?: string) { + super(message); + + this.code = code; + } + +} diff --git a/src/app/core/emulator/components/capture-media/capture-media.html b/src/app/core/emulator/components/capture-media/capture-media.html new file mode 100644 index 000000000..b2afae1dd --- /dev/null +++ b/src/app/core/emulator/components/capture-media/capture-media.html @@ -0,0 +1,66 @@ + + + {{ title | translate }} + + + {{ 'core.cancel' | translate }} + {{ 'core.done' | translate }} + + + + + +
+ + + + + + + + + {{ 'core.capturedimage' | translate }} + + +
+ + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/core/emulator/components/capture-media/capture-media.scss b/src/app/core/emulator/components/capture-media/capture-media.scss new file mode 100644 index 000000000..8e911e0b4 --- /dev/null +++ b/src/app/core/emulator/components/capture-media/capture-media.scss @@ -0,0 +1,71 @@ +:host { + .core-av-wrapper { + // @todo: For some reason it takes a while to apply these styles, first it's displayed too big and then it's resized. + width: 100%; + height: 100%; + + .core-webcam-image-canvas { + display: none; + } + + .core-audio-record-container { + width: 100%; + height: 100%; + position: relative; + + .core-audio-canvas { + width: 100%; + height: 100%; + } + + .core-audio-captured { + width: 100%; + } + + .button { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: auto; + height: 120px; + width: 120px; + + .icon { + font-size: 120px; + width: auto; + } + } + + audio { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: auto; + } + } + + video, img { + width: 100%; + height: 100%; + display: table-cell; + text-align: center; + vertical-align: middle; + object-fit: contain; + + &.core-webcam-stream { + -webkit-transform: scaleX(-1); + transform: scaleX(-1); + } + } + } + + + ion-footer { + background-color: var(--gray); + border-top: 1px solid var(--gray-dark); + } +} diff --git a/src/app/core/emulator/components/capture-media/capture-media.ts b/src/app/core/emulator/components/capture-media/capture-media.ts new file mode 100644 index 000000000..a4ea47e75 --- /dev/null +++ b/src/app/core/emulator/components/capture-media/capture-media.ts @@ -0,0 +1,588 @@ +// (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 { Component, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectorRef, Input } from '@angular/core'; +import { MediaObject } from '@ionic-native/media/ngx'; +import { FileEntry } from '@ionic-native/file/ngx'; +import { MediaFile } from '@ionic-native/media-capture/ngx'; + +import { CoreApp } from '@services/app'; +import { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { Platform, ModalController, Media, Translate } from '@singletons/core.singletons'; +import { CoreError } from '@classes/errors/error'; +import { CoreCaptureError } from '@classes/errors/captureerror'; +import { CoreCanceledError } from '@classes/errors/cancelederror'; + +/** + * Page to capture media in browser, or to capture audio in mobile devices. + */ +@Component({ + selector: 'core-emulator-capture-media', + templateUrl: 'capture-media.html', + styleUrls: ['capture-media.scss'], +}) +export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { + + @Input() type?: 'audio' | 'video' | 'image' | 'captureimage'; + @Input() maxTime?: number; // Max time to capture. + @Input() facingMode?: string; // Camera facing mode. + @Input() mimetype?: string; + @Input() extension?: string; + @Input() quality?: number; // Only for images. + @Input() returnDataUrl?: boolean; // Whether it should return a data img. Only for images. + + @ViewChild('streamVideo') streamVideo?: ElementRef; + @ViewChild('previewVideo') previewVideo?: ElementRef; + @ViewChild('imgCanvas') imgCanvas?: ElementRef; + @ViewChild('previewImage') previewImage?: ElementRef; + @ViewChild('streamAudio') streamAudio?: ElementRef; + @ViewChild('previewAudio') previewAudio?: ElementRef; + + title?: string; // The title of the page. + isAudio?: boolean; // Whether it should capture audio. + isVideo?: boolean; // Whether it should capture video. + isImage?: boolean; // Whether it should capture image. + readyToCapture?: boolean; // Whether it's ready to capture. + hasCaptured?: boolean; // Whether it has captured something. + isCapturing?: boolean; // Whether it's capturing. + resetChrono?: boolean; // Boolean to reset the chrono. + isCordovaAudioCapture?: boolean; // Whether it's capturing audio using Cordova plugin. + + protected isCaptureImage?: boolean; // To identify if it's capturing an image using media capture plugin (instead of camera). + protected mediaRecorder?: MediaRecorder; // To record video/audio. + protected previewMedia?: HTMLAudioElement | HTMLVideoElement; // The element to preview the audio/video captured. + protected mediaBlob?: Blob; // A Blob where the captured data is stored. + protected localMediaStream?: MediaStream; + protected audioDrawer?: {start: () => void; stop: () => void }; // To start/stop the display of audio sound. + + // Variables for Cordova Media capture. + protected mediaFile?: MediaObject; + protected filePath?: string; + protected fileEntry?: FileEntry; + + constructor( + protected changeDetectorRef: ChangeDetectorRef, + ) {} + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initVariables(); + + if (this.isCordovaAudioCapture) { + this.initCordovaMediaPlugin(); + } else { + this.initHtmlCapture(); + } + } + + /** + * Initialize some variables based on the params. + */ + protected initVariables(): void { + this.facingMode = this.facingMode || 'environment'; + this.quality = this.quality || 0.92; + + if (this.type == 'captureimage') { + this.isCaptureImage = true; + this.type = 'image'; + } + + // Initialize some data based on the type of media to capture. + if (this.type == 'video') { + this.isVideo = true; + this.title = 'core.capturevideo'; + } else if (this.type == 'audio') { + this.isAudio = true; + this.title = 'core.captureaudio'; + } else if (this.type == 'image') { + this.isImage = true; + this.title = 'core.captureimage'; + } + + this.isCordovaAudioCapture = CoreApp.instance.isMobile() && this.isAudio; + + if (this.isCordovaAudioCapture) { + this.extension = Platform.instance.is('ios') ? 'wav' : 'aac'; + this.returnDataUrl = false; + } + } + + /** + * Init recording with Cordova media plugin. + * + * @return Promise resolved when ready. + */ + protected async initCordovaMediaPlugin(): Promise { + this.filePath = this.getFilePath(); + let absolutePath = CoreTextUtils.instance.concatenatePaths(CoreFile.instance.getBasePathInstant(), this.filePath); + + if (Platform.instance.is('ios')) { + // In iOS we need to remove the file:// part. + absolutePath = absolutePath.replace(/^file:\/\//, ''); + } + + try { + // First create the file. + this.fileEntry = await CoreFile.instance.createFile(this.filePath); + + // Now create the media instance. + this.mediaFile = Media.instance.create(absolutePath); + this.readyToCapture = true; + this.previewMedia = this.previewAudio?.nativeElement; + } catch (error) { + this.dismissWithError(-1, error.message || error); + } + } + + /** + * Init HTML recorder for browser + * . + * + * @return Promise resolved when done. + */ + protected async initHtmlCapture(): Promise { + const constraints = { + video: this.isAudio ? false : { facingMode: this.facingMode }, + audio: !this.isImage, + }; + + try { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + + let chunks: Blob[] = []; + this.localMediaStream = stream; + + if (!this.isImage) { + if (this.isVideo) { + this.previewMedia = this.previewVideo?.nativeElement; + } else { + this.previewMedia = this.previewAudio?.nativeElement; + this.initAudioDrawer(this.localMediaStream); + this.audioDrawer?.start(); + } + + this.mediaRecorder = new MediaRecorder(this.localMediaStream, { mimeType: this.mimetype }); + + // When video or audio is recorded, add it to the list of chunks. + this.mediaRecorder.ondataavailable = (e): void => { + if (e.data.size > 0) { + chunks.push(e.data); + } + }; + + // When recording stops, create a Blob element with the recording and set it to the video or audio. + this.mediaRecorder.onstop = (): void => { + this.mediaBlob = new Blob(chunks); + chunks = []; + + if (this.previewMedia) { + this.previewMedia.src = window.URL.createObjectURL(this.mediaBlob); + } + }; + } + + if (!this.isImage && !this.isVideo) { + // It's ready to capture. + this.readyToCapture = true; + + return; + } + + if (!this.streamVideo) { + throw new CoreError('Video element not found.'); + } + + let hasLoaded = false; + + // If stream isn't ready in a while, show error. + const waitTimeout = window.setTimeout(() => { + if (!hasLoaded) { + // Show error. + hasLoaded = true; + this.dismissWithError(-1, 'Cannot connect to webcam.'); + } + }, 10000); + + // Listen for stream ready to display the stream. + this.streamVideo.nativeElement.onloadedmetadata = (): void => { + if (hasLoaded) { + // Already loaded or timeout triggered, stop. + return; + } + + hasLoaded = true; + clearTimeout(waitTimeout); + this.readyToCapture = true; + this.streamVideo!.nativeElement.onloadedmetadata = null; + // Force change detection. Angular doesn't detect these async operations. + this.changeDetectorRef.detectChanges(); + }; + + // Set the stream as the source of the video. + if ('srcObject' in this.streamVideo.nativeElement) { + this.streamVideo.nativeElement.srcObject = this.localMediaStream; + } else { + // Fallback for old browsers. + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/srcObject#Examples + this.streamVideo.nativeElement.src = window.URL.createObjectURL(this.localMediaStream); + } + } catch (error) { + this.dismissWithError(-1, error.message || error); + } + } + + /** + * Initialize the audio drawer. This code has been extracted from MDN's example on MediaStream Recording: + * https://github.com/mdn/web-dictaphone + * + * @param stream Stream returned by getUserMedia. + */ + protected initAudioDrawer(stream: MediaStream): void { + if (!this.streamAudio) { + return; + } + + let skip = true; + let running = false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const audioCtx = new (window.AudioContext || ( window).webkitAudioContext)(); + const canvasCtx = this.streamAudio.nativeElement.getContext('2d'); + const source = audioCtx.createMediaStreamSource(stream); + const analyser = audioCtx.createAnalyser(); + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + const width = this.streamAudio.nativeElement.width; + const height = this.streamAudio.nativeElement.height; + const drawAudio = (): void => { + if (!running) { + return; + } + + // Update the draw every animation frame. + requestAnimationFrame(drawAudio); + + // Skip half of the frames to improve performance, shouldn't affect the smoothness. + skip = !skip; + if (skip) { + return; + } + + const sliceWidth = width / bufferLength; + let x = 0; + + analyser.getByteTimeDomainData(dataArray); + + canvasCtx.fillStyle = 'rgb(200, 200, 200)'; + canvasCtx.fillRect(0, 0, width, height); + + canvasCtx.lineWidth = 1; + canvasCtx.strokeStyle = 'rgb(0, 0, 0)'; + + canvasCtx.beginPath(); + + for (let i = 0; i < bufferLength; i++) { + const v = dataArray[i] / 128.0; + const y = v * height / 2; + + if (i === 0) { + canvasCtx.moveTo(x, y); + } else { + canvasCtx.lineTo(x, y); + } + + x += sliceWidth; + } + + canvasCtx.lineTo(width, height / 2); + canvasCtx.stroke(); + }; + + analyser.fftSize = 2048; + source.connect(analyser); + + this.audioDrawer = { + start: (): void => { + if (running) { + return; + } + + running = true; + drawAudio(); + }, + stop: (): void => { + running = false; + }, + }; + } + + /** + * Main action clicked: record or stop recording. + */ + async actionClicked(): Promise { + if (this.isCapturing) { + // It's capturing, stop. + this.stopCapturing(); + this.changeDetectorRef.detectChanges(); + + return; + } + + if (!this.isImage) { + // Start the capture. + this.isCapturing = true; + this.resetChrono = false; + + if (this.isCordovaAudioCapture) { + this.mediaFile?.startRecord(); + if (this.previewMedia) { + this.previewMedia.src = ''; + } + } else { + this.mediaRecorder?.start(); + } + + this.changeDetectorRef.detectChanges(); + } else { + if (!this.imgCanvas) { + return; + } + + // Get the image from the video and set it to the canvas, using video width/height. + const width = this.streamVideo?.nativeElement.videoWidth; + const height = this.streamVideo?.nativeElement.videoHeight; + const loadingModal = await CoreDomUtils.instance.showModalLoading(); + + + this.imgCanvas.nativeElement.width = width; + this.imgCanvas.nativeElement.height = height; + this.imgCanvas.nativeElement.getContext('2d').drawImage(this.streamVideo?.nativeElement, 0, 0, width, height); + + // Convert the image to blob and show it in an image element. + this.imgCanvas.nativeElement.toBlob((blob) => { + loadingModal.dismiss(); + + this.mediaBlob = blob; + this.previewImage?.nativeElement.setAttribute('src', window.URL.createObjectURL(this.mediaBlob)); + this.hasCaptured = true; + }, this.mimetype, this.quality); + } + } + + /** + * User cancelled. + */ + async cancel(): Promise { + if (this.hasCaptured) { + try { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit')); + } catch { + // Canceled. + return; + } + } + + // Send a "cancelled" error like the Cordova plugin does. + this.dismissWithCanceledError('Canceled.', 'Camera cancelled'); + + if (this.isCordovaAudioCapture && this.filePath) { + // Delete the tmp file. + CoreFile.instance.removeFile(this.filePath); + } + } + + /** + * Discard the captured media. + */ + discard(): void { + this.previewMedia?.pause(); + this.streamVideo?.nativeElement.play(); + this.audioDrawer?.start(); + + this.hasCaptured = false; + this.isCapturing = false; + this.resetChrono = true; + delete this.mediaBlob; + this.changeDetectorRef.detectChanges(); + } + + /** + * Close the modal, returning some data (success). + * + * @param data Data to return. + */ + dismissWithData(data?: [MediaFile] | string): void { + ModalController.instance.dismiss(data, 'success'); + } + + /** + * Close the modal, returning an error. + * + * @param code Error code. Will not be used if it's a Camera capture. + * @param message Error message. + * @param cameraMessage A specific message to use if it's a Camera capture. If not set, message will be used. + */ + dismissWithCanceledError(message: string, cameraMessage?: string): void { + const isCamera = this.isImage && !this.isCaptureImage; + const error = isCamera ? new CoreCanceledError(cameraMessage || message) : new CoreCaptureError(3, message); + + ModalController.instance.dismiss(error, 'error'); + } + + /** + * Close the modal, returning an error. + * + * @param code Error code. Will not be used if it's a Camera capture. + * @param message Error message. + * @param cameraMessage A specific message to use if it's a Camera capture. If not set, message will be used. + */ + dismissWithError(code: number, message: string, cameraMessage?: string): void { + const isCamera = this.isImage && !this.isCaptureImage; + const error = isCamera ? new CoreError(cameraMessage || message) : new CoreCaptureError(code, message); + + ModalController.instance.dismiss(error, 'error'); + } + + /** + * Done capturing, write the file. + */ + async done(): Promise { + if (this.returnDataUrl) { + // Return the image as a base64 string. + this.dismissWithData(( this.imgCanvas?.nativeElement).toDataURL(this.mimetype, this.quality)); + + return; + } + + if (!this.mediaBlob && !this.isCordovaAudioCapture) { + // Shouldn't happen. + CoreDomUtils.instance.showErrorModal('Please capture the media first.'); + + return; + } + + let fileEntry = this.fileEntry; + const loadingModal = await CoreDomUtils.instance.showModalLoading(); + + try { + if (!this.isCordovaAudioCapture) { + // Capturing in browser. Write the blob in a file. + if (!this.mediaBlob) { + // Shouldn't happen. + throw new Error('Please capture the media first.'); + } + + fileEntry = await CoreFile.instance.writeFile(this.getFilePath(), this.mediaBlob); + } + + if (!fileEntry) { + throw new CoreError('File not found.'); + } + + if (this.isImage && !this.isCaptureImage) { + this.dismissWithData(fileEntry.toURL()); + } else { + // The capture plugin should return a MediaFile, not a FileEntry. Convert it. + const metadata = await CoreFile.instance.getMetadata(fileEntry); + + let mimetype: string | undefined; + if (this.extension) { + mimetype = CoreMimetypeUtils.instance.getMimeType(this.extension); + } + + const mediaFile: MediaFile = { + name: fileEntry.name, + fullPath: fileEntry.nativeURL || fileEntry.fullPath, + type: mimetype || '', + lastModifiedDate: metadata.modificationTime, + size: metadata.size, + getFormatData: (): void => { + // Nothing to do. + }, + }; + + this.dismissWithData([mediaFile]); + } + } catch (err) { + CoreDomUtils.instance.showErrorModal(err); + } finally { + loadingModal.dismiss(); + } + } + + /** + * Get path to the file where the media will be stored. + * + * @return Path. + */ + protected getFilePath(): string { + const fileName = this.type + '_' + CoreTimeUtils.instance.readableTimestamp() + '.' + this.extension; + + return CoreTextUtils.instance.concatenatePaths(CoreFileProvider.TMPFOLDER, 'media/' + fileName); + } + + /** + * Stop capturing. Only for video and audio. + */ + stopCapturing(): void { + this.isCapturing = false; + this.hasCaptured = true; + + if (this.isCordovaAudioCapture) { + this.mediaFile?.stopRecord(); + if (this.previewMedia && this.fileEntry) { + this.previewMedia.src = CoreFile.instance.convertFileSrc(this.fileEntry.toURL()); + } + } else { + this.streamVideo && this.streamVideo.nativeElement.pause(); + this.audioDrawer && this.audioDrawer.stop(); + this.mediaRecorder && this.mediaRecorder.stop(); + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.mediaFile?.release(); + + if (this.localMediaStream) { + const tracks = this.localMediaStream.getTracks(); + tracks.forEach((track) => { + track.stop(); + }); + } + this.streamVideo?.nativeElement.pause(); + this.previewMedia?.pause(); + this.audioDrawer?.stop(); + delete this.mediaBlob; + } + +} + +export type CaptureMediaComponentInputs = { + type: 'audio' | 'video' | 'image' | 'captureimage'; + maxTime?: number; // Max time to capture. + facingMode?: string; // Camera facing mode. + mimetype?: string; + extension?: string; + quality?: number; // Only for images. + returnDataUrl?: boolean; // Whether it should return a data img. Only for images. +}; diff --git a/src/app/core/emulator/components/components.module.ts b/src/app/core/emulator/components/components.module.ts new file mode 100644 index 000000000..c5d5c3e34 --- /dev/null +++ b/src/app/core/emulator/components/components.module.ts @@ -0,0 +1,41 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@app/components/components.module'; +import { CoreDirectivesModule } from '@app/directives/directives.module'; +import { CorePipesModule } from '@app/pipes/pipes.module'; +import { CoreEmulatorCaptureMediaComponent } from './capture-media/capture-media'; + +@NgModule({ + declarations: [ + CoreEmulatorCaptureMediaComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + ], + exports: [ + CoreEmulatorCaptureMediaComponent, + ], +}) +export class CoreEmulatorComponentsModule {} diff --git a/src/app/core/emulator/emulator.module.ts b/src/app/core/emulator/emulator.module.ts index 173d6beeb..db41f24f5 100644 --- a/src/app/core/emulator/emulator.module.ts +++ b/src/app/core/emulator/emulator.module.ts @@ -17,8 +17,12 @@ import { Platform } from '@ionic/angular'; import { CoreInitDelegate } from '@services/init'; import { CoreEmulatorHelperProvider } from './services/helper'; +import { CoreEmulatorCaptureHelperProvider } from './services/capture.helper'; +import { CoreEmulatorComponentsModule } from './components/components.module'; // Ionic Native services. +import { Camera } from '@ionic-native/camera/ngx'; +import { Chooser } from '@ionic-native/chooser/ngx'; import { Clipboard } from '@ionic-native/clipboard/ngx'; import { Device } from '@ionic-native/device/ngx'; import { Diagnostic } from '@ionic-native/diagnostic/ngx'; @@ -31,6 +35,8 @@ import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; import { WebView } from '@ionic-native/ionic-webview/ngx'; import { Keyboard } from '@ionic-native/keyboard/ngx'; import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; +import { Media } from '@ionic-native/media/ngx'; +import { MediaCapture } from '@ionic-native/media-capture/ngx'; import { Network } from '@ionic-native/network/ngx'; import { Push } from '@ionic-native/push/ngx'; import { QRScanner } from '@ionic-native/qr-scanner/ngx'; @@ -41,12 +47,14 @@ import { WebIntent } from '@ionic-native/web-intent/ngx'; import { Zip } from '@ionic-native/zip/ngx'; // Mock services. +import { CameraMock } from './services/camera'; import { ClipboardMock } from './services/clipboard'; import { FileMock } from './services/file'; import { FileOpenerMock } from './services/file-opener'; import { FileTransferMock } from './services/file-transfer'; import { GeolocationMock } from './services/geolocation'; import { InAppBrowserMock } from './services/inappbrowser'; +import { MediaCaptureMock } from './services/media-capture'; import { NetworkMock } from './services/network'; import { ZipMock } from './services/zip'; @@ -63,9 +71,17 @@ import { ZipMock } from './services/zip'; declarations: [ ], imports: [ + CoreEmulatorComponentsModule, ], providers: [ CoreEmulatorHelperProvider, + CoreEmulatorCaptureHelperProvider, + { + provide: Camera, + deps: [Platform], + useFactory: (platform: Platform): Camera => platform.is('cordova') ? new Camera() : new CameraMock(), + }, + Chooser, { provide: Clipboard, deps: [Platform], // Use platform instead of AppProvider to prevent errors with singleton injection. @@ -101,6 +117,16 @@ import { ZipMock } from './services/zip'; }, Keyboard, LocalNotifications, + { + provide: Media, + deps: [], + useFactory: (): Media => new Media(), + }, + { + provide: MediaCapture, + deps: [Platform], + useFactory: (platform: Platform): MediaCapture => platform.is('cordova') ? new MediaCapture() : new MediaCaptureMock(), + }, { provide: Network, deps: [Platform], diff --git a/src/app/core/emulator/services/camera.ts b/src/app/core/emulator/services/camera.ts new file mode 100644 index 000000000..49c0fee01 --- /dev/null +++ b/src/app/core/emulator/services/camera.ts @@ -0,0 +1,47 @@ +// (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 { Camera, CameraOptions } from '@ionic-native/camera/ngx'; + +import { CoreEmulatorCaptureHelper } from './capture.helper'; + +/** + * Emulates the Cordova Camera plugin in browser. + */ +@Injectable() +export class CameraMock extends Camera { + + /** + * Remove intermediate image files that are kept in temporary storage after calling camera.getPicture. + * + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cleanup(): Promise { + // This function is iOS only, nothing to do. + return Promise.resolve(); + } + + /** + * Take a picture. + * + * @param options Options that you want to pass to the camera. + * @return Promise resolved when captured. + */ + getPicture(options: CameraOptions): Promise { + return CoreEmulatorCaptureHelper.instance.captureMedia('image', options); + } + +} diff --git a/src/app/core/emulator/services/capture.helper.ts b/src/app/core/emulator/services/capture.helper.ts new file mode 100644 index 000000000..a8589a625 --- /dev/null +++ b/src/app/core/emulator/services/capture.helper.ts @@ -0,0 +1,218 @@ +// (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 { CoreCanceledError } from '@/app/classes/errors/cancelederror'; +import { Injectable } from '@angular/core'; +import { CameraOptions } from '@ionic-native/camera/ngx'; +import { CaptureAudioOptions, CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx'; + +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { makeSingleton, ModalController } from '@singletons/core.singletons'; +import { CaptureMediaComponentInputs, CoreEmulatorCaptureMediaComponent } from '../components/capture-media/capture-media'; + +/** + * Helper service with some features to capture media (image, audio, video). + */ +@Injectable() +export class CoreEmulatorCaptureHelperProvider { + + protected possibleAudioMimeTypes = { + 'audio/webm': 'weba', + 'audio/ogg': 'ogg', + }; + + protected possibleVideoMimeTypes = { + 'video/webm;codecs=vp9': 'webm', + 'video/webm;codecs=vp8': 'webm', + 'video/ogg': 'ogv', + }; + + videoMimeType?: string; + audioMimeType?: string; + + /** + * Capture media (image, audio, video). + * + * @param type Type of media: image, audio, video. + * @param options Optional options. + * @return Promise resolved when captured, rejected if error. + */ + captureMedia(type: 'image', options?: MockCameraOptions): Promise; + captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise; + captureMedia(type: 'audio', options?: MockCaptureAudioOptions): Promise; + captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise; + async captureMedia( + type: 'image' | 'captureimage' | 'audio' | 'video', + options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureAudioOptions | MockCaptureVideoOptions, + ): Promise { + options = options || {}; + + // Build the params to send to the modal. + const params: CaptureMediaComponentInputs = { + type: type, + }; + + // Initialize some data based on the type of media to capture. + if (type == 'video') { + const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); + params.mimetype = mimeAndExt.mimetype; + params.extension = mimeAndExt.extension; + } else if (type == 'audio') { + const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); + params.mimetype = mimeAndExt.mimetype; + params.extension = mimeAndExt.extension; + } else if (type == 'image') { + if ('sourceType' in options && options.sourceType !== undefined && options.sourceType != 1) { + return Promise.reject('This source type is not supported in browser.'); + } + + if ('cameraDirection' in options && options.cameraDirection == 1) { + params.facingMode = 'user'; + } + + if ('encodingType' in options && options.encodingType == 1) { + params.mimetype = 'image/png'; + params.extension = 'png'; + } else { + params.mimetype = 'image/jpeg'; + params.extension = 'jpeg'; + } + + if ('quality' in options && options.quality !== undefined && options.quality >= 0 && options.quality <= 100) { + params.quality = options.quality / 100; + } + + if ('destinationType' in options && options.destinationType == 0) { + params.returnDataUrl = true; + } + } + + if ('duration' in options && options.duration) { + params.maxTime = options.duration * 1000; + } + + const modal = await ModalController.instance.create({ + component: CoreEmulatorCaptureMediaComponent, + cssClass: 'core-modal-fullscreen', + componentProps: params, + }); + + modal.present(); + + const result = await modal.onDidDismiss(); + + if (result.role == 'success') { + return result.data; + } else { + throw result.data; + } + } + + /** + * Get the mimetype and extension to capture media. + * + * @param type Type of media: image, audio, video. + * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. + * @return An object with mimetype and extension to use. + */ + protected getMimeTypeAndExtension(type: string, mimetypes?: string[]): { extension?: string; mimetype?: string } { + const result: { extension?: string; mimetype?: string } = {}; + + if (mimetypes?.length) { + // Search for a supported mimetype. + for (let i = 0; i < mimetypes.length; i++) { + const mimetype = mimetypes[i]; + const matches = mimetype.match(new RegExp('^' + type + '/')); + + if (matches?.length && window.MediaRecorder.isTypeSupported(mimetype)) { + result.mimetype = mimetype; + break; + } + } + } + + if (result.mimetype) { + // Found a supported mimetype in the mimetypes array, get the extension. + result.extension = CoreMimetypeUtils.instance.getExtension(result.mimetype); + } else if (type == 'video') { + // No mimetype found, use default extension. + result.mimetype = this.videoMimeType; + result.extension = this.possibleVideoMimeTypes[result.mimetype!]; + } else if (type == 'audio') { + // No mimetype found, use default extension. + result.mimetype = this.audioMimeType; + result.extension = this.possibleAudioMimeTypes[result.mimetype!]; + } + + return result; + } + + /** + * Init the getUserMedia function, using a deprecated function as fallback if the new one doesn't exist. + * + * @return Whether the function is supported. + */ + protected initGetUserMedia(): boolean { + return !!navigator.mediaDevices.getUserMedia; + } + + /** + * Initialize the mimetypes to use when capturing. + */ + protected initMimeTypes(): void { + // Determine video and audio mimetype to use. + for (const mimeType in this.possibleVideoMimeTypes) { + if (window.MediaRecorder.isTypeSupported(mimeType)) { + this.videoMimeType = mimeType; + break; + } + } + + for (const mimeType in this.possibleAudioMimeTypes) { + if (window.MediaRecorder.isTypeSupported(mimeType)) { + this.audioMimeType = mimeType; + break; + } + } + } + + /** + * Load the Mocks that need it. + * + * @return Promise resolved when loaded. + */ + load(): Promise { + if (typeof window.MediaRecorder != 'undefined' && this.initGetUserMedia()) { + this.initMimeTypes(); + } + + return Promise.resolve(); + } + +} + +export class CoreEmulatorCaptureHelper extends makeSingleton(CoreEmulatorCaptureHelperProvider) {} + +export interface MockCameraOptions extends CameraOptions { + mimetypes?: string[]; // Allowed mimetypes. +} +export interface MockCaptureImageOptions extends CaptureImageOptions { + mimetypes?: string[]; // Allowed mimetypes. +} +export interface MockCaptureAudioOptions extends CaptureAudioOptions { + mimetypes?: string[]; // Allowed mimetypes. +} +export interface MockCaptureVideoOptions extends CaptureVideoOptions { + mimetypes?: string[]; // Allowed mimetypes. +} diff --git a/src/app/core/emulator/services/media-capture.ts b/src/app/core/emulator/services/media-capture.ts new file mode 100644 index 000000000..9818ea22b --- /dev/null +++ b/src/app/core/emulator/services/media-capture.ts @@ -0,0 +1,62 @@ +// (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 { + MediaCapture, + CaptureAudioOptions, + CaptureImageOptions, + CaptureVideoOptions, + MediaFile, +} from '@ionic-native/media-capture/ngx'; + +import { CoreEmulatorCaptureHelper } from './capture.helper'; + +/** + * Emulates the Cordova MediaCapture plugin in browser. + */ +@Injectable() +export class MediaCaptureMock extends MediaCapture { + + /** + * Start the audio recorder application and return information about captured audio clip files. + * + * @param options Options. + * @return Promise resolved when captured. + */ + captureAudio(options: CaptureAudioOptions): Promise { + return CoreEmulatorCaptureHelper.instance.captureMedia('audio', options); + } + + /** + * Start the camera application and return information about captured image files. + * + * @param options Options. + * @return Promise resolved when captured. + */ + captureImage(options: CaptureImageOptions): Promise { + return CoreEmulatorCaptureHelper.instance.captureMedia('captureimage', options); + } + + /** + * Start the video recorder application and return information about captured video clip files. + * + * @param options Options. + * @return Promise resolved when captured. + */ + captureVideo(options: CaptureVideoOptions): Promise { + return CoreEmulatorCaptureHelper.instance.captureMedia('video', options); + } + +} From 3c634c4f6e0925e4631061446aa6de5a0366d541 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 6 Nov 2020 15:26:20 +0100 Subject: [PATCH 2/9] MOBILE-3585 pipe: Implement seconds to HMS pipe --- src/app/pipes/pipes.module.ts | 3 ++ src/app/pipes/seconds-to-hms.pipe.ts | 69 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/app/pipes/seconds-to-hms.pipe.ts diff --git a/src/app/pipes/pipes.module.ts b/src/app/pipes/pipes.module.ts index 492bf7e08..ed1d765d8 100644 --- a/src/app/pipes/pipes.module.ts +++ b/src/app/pipes/pipes.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { CoreCreateLinksPipe } from './create-links.pipe'; import { CoreFormatDatePipe } from './format-date.pipe'; import { CoreNoTagsPipe } from './no-tags.pipe'; +import { CoreSecondsToHMSPipe } from './seconds-to-hms.pipe'; import { CoreTimeAgoPipe } from './time-ago.pipe'; import { CoreBytesToSizePipe } from './bytes-to-size.pipe'; @@ -26,6 +27,7 @@ import { CoreBytesToSizePipe } from './bytes-to-size.pipe'; CoreTimeAgoPipe, CoreFormatDatePipe, CoreBytesToSizePipe, + CoreSecondsToHMSPipe, ], imports: [], exports: [ @@ -34,6 +36,7 @@ import { CoreBytesToSizePipe } from './bytes-to-size.pipe'; CoreTimeAgoPipe, CoreFormatDatePipe, CoreBytesToSizePipe, + CoreSecondsToHMSPipe, ], }) export class CorePipesModule {} diff --git a/src/app/pipes/seconds-to-hms.pipe.ts b/src/app/pipes/seconds-to-hms.pipe.ts new file mode 100644 index 000000000..f483237f0 --- /dev/null +++ b/src/app/pipes/seconds-to-hms.pipe.ts @@ -0,0 +1,69 @@ +// (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 { Pipe, PipeTransform } from '@angular/core'; + +import { CoreTextUtils } from '@services/utils/text'; +import { CoreLogger } from '@singletons/logger'; +import { CoreConstants } from '@core/constants'; + +/** + * Pipe to convert a number of seconds to Hours:Minutes:Seconds. + * + * This converts a number of seconds to Hours:Minutes:Seconds. If the number of seconds is negative, returns 00:00:00. + */ +@Pipe({ + name: 'coreSecondsToHMS', +}) +export class CoreSecondsToHMSPipe implements PipeTransform { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreSecondsToHMSPipe'); + } + + /** + * Convert a number of seconds to Hours:Minutes:Seconds. + * + * @param seconds Number of seconds. + * @return Formatted seconds. + */ + transform(seconds: string | number): string { + if (!seconds || seconds < 0) { + seconds = 0; + } else if (typeof seconds == 'string') { + // Convert the value to a number. + const numberSeconds = parseInt(seconds, 10); + if (isNaN(numberSeconds)) { + this.logger.error('Invalid value received', seconds); + + return seconds; + } + seconds = numberSeconds; + } + + // Don't allow decimals. + seconds = Math.floor(seconds); + + const hours = Math.floor(seconds / CoreConstants.SECONDS_HOUR); + seconds -= hours * CoreConstants.SECONDS_HOUR; + const minutes = Math.floor(seconds / CoreConstants.SECONDS_MINUTE); + seconds -= minutes * CoreConstants.SECONDS_MINUTE; + + return CoreTextUtils.instance.twoDigits(hours) + ':' + CoreTextUtils.instance.twoDigits(minutes) + ':' + + CoreTextUtils.instance.twoDigits(seconds); + } + +} From 105430c87786ce247c19bba04b0ba831d7a7f655 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 6 Nov 2020 15:31:28 +0100 Subject: [PATCH 3/9] MOBILE-3585 components: Implement core-chrono component --- src/app/components/chrono/chrono.ts | 128 +++++++++++++++++++++ src/app/components/chrono/core-chrono.html | 1 + src/app/components/components.module.ts | 3 + 3 files changed, 132 insertions(+) create mode 100644 src/app/components/chrono/chrono.ts create mode 100644 src/app/components/chrono/core-chrono.html diff --git a/src/app/components/chrono/chrono.ts b/src/app/components/chrono/chrono.ts new file mode 100644 index 000000000..179ad69f8 --- /dev/null +++ b/src/app/components/chrono/chrono.ts @@ -0,0 +1,128 @@ +// (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 { + Component, + Input, + OnInit, + OnChanges, + OnDestroy, + Output, + EventEmitter, + SimpleChange, + ChangeDetectorRef, +} from '@angular/core'; + +/** + * This component shows a chronometer in format HH:MM:SS. + * + * If no startTime is provided, it will start at 00:00:00. + * If an endTime is provided, the chrono will stop and emit an event in the onEnd output when that number of milliseconds is + * reached. E.g. if startTime=60000 and endTime=120000, the chrono will start at 00:01:00 and end when it reaches 00:02:00. + * + * This component has 2 boolean inputs to control the timer: running (to start and stop it) and reset. + * + * Example usage: + * + */ +@Component({ + selector: 'core-chrono', + templateUrl: 'core-chrono.html', +}) +export class CoreChronoComponent implements OnInit, OnChanges, OnDestroy { + + @Input() running?: boolean; // Set it to true to start the chrono. Set it to false to stop it. + @Input() startTime = 0; // Number of milliseconds to put in the chrono before starting. + @Input() endTime?: number; // Number of milliseconds to stop the chrono. + @Input() reset?: boolean; // Set it to true to reset the chrono. + @Output() onEnd: EventEmitter; // Will emit an event when the endTime is reached. + + time = 0; + protected interval?: number; + + constructor(protected changeDetectorRef: ChangeDetectorRef) { + this.onEnd = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.time = this.startTime || 0; + } + + /** + * Component being changed. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes && changes.running) { + if (changes.running.currentValue) { + this.start(); + } else { + this.stop(); + } + } + if (changes && changes.reset && changes.reset.currentValue) { + this.resetChrono(); + } + } + + /** + * Reset the chrono, stopping it and setting it to startTime. + */ + protected resetChrono(): void { + this.stop(); + this.time = this.startTime || 0; + } + + /** + * Start the chrono if it isn't running. + */ + protected start(): void { + if (this.interval) { + // Already setup. + return; + } + + let lastExecTime = Date.now(); + + this.interval = window.setInterval(() => { + // Increase the chrono. + this.time += Date.now() - lastExecTime; + lastExecTime = Date.now(); + + if (typeof this.endTime != 'undefined' && this.time > this.endTime) { + // End time reached, stop the timer and call the end function. + this.stop(); + this.onEnd.emit(); + } + + // Force change detection. Angular doesn't detect these async operations. + this.changeDetectorRef.detectChanges(); + }, 200); + } + + /** + * Stop the chrono, leaving the same time it has. + */ + protected stop(): void { + clearInterval(this.interval); + delete this.interval; + } + + ngOnDestroy(): void { + this.stop(); + } + +} diff --git a/src/app/components/chrono/core-chrono.html b/src/app/components/chrono/core-chrono.html new file mode 100644 index 000000000..5f0f28ade --- /dev/null +++ b/src/app/components/chrono/core-chrono.html @@ -0,0 +1 @@ +{{ time / 1000 | coreSecondsToHMS }} \ No newline at end of file diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index 1e314e6d9..b67263e6a 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -17,6 +17,7 @@ import { CommonModule } from '@angular/common'; import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; +import { CoreChronoComponent } from './chrono/chrono'; import { CoreDownloadRefreshComponent } from './download-refresh/download-refresh'; import { CoreFileComponent } from './file/file'; import { CoreIconComponent } from './icon/icon'; @@ -35,6 +36,7 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; @NgModule({ declarations: [ + CoreChronoComponent, CoreDownloadRefreshComponent, CoreFileComponent, CoreIconComponent, @@ -56,6 +58,7 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; CorePipesModule, ], exports: [ + CoreChronoComponent, CoreDownloadRefreshComponent, CoreFileComponent, CoreIconComponent, From f6a64ae1228f39000fd6afc75a2ad7a2cfa85206 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 6 Nov 2020 15:32:00 +0100 Subject: [PATCH 4/9] MOBILE-3585 core: Implement fileuploader --- src/app/classes/site.ts | 15 +- .../fileuploader/fileuploader-init.module.ts | 53 ++ src/app/core/fileuploader/lang/en.json | 29 + .../services/fileuploader.delegate.ts | 198 ++++ .../services/fileuploader.helper.ts | 875 ++++++++++++++++++ .../fileuploader/services/fileuploader.ts | 664 +++++++++++++ .../fileuploader/services/handlers/album.ts | 77 ++ .../fileuploader/services/handlers/audio.ts | 92 ++ .../fileuploader/services/handlers/camera.ts | 77 ++ .../fileuploader/services/handlers/file.ts | 158 ++++ .../fileuploader/services/handlers/video.ts | 92 ++ src/app/services/app.ts | 13 +- src/app/services/file.ts | 10 + src/app/services/local-notifications.ts | 4 +- src/app/services/utils/mimetype.ts | 10 +- src/app/services/ws.ts | 17 +- 16 files changed, 2373 insertions(+), 11 deletions(-) create mode 100644 src/app/core/fileuploader/fileuploader-init.module.ts create mode 100644 src/app/core/fileuploader/lang/en.json create mode 100644 src/app/core/fileuploader/services/fileuploader.delegate.ts create mode 100644 src/app/core/fileuploader/services/fileuploader.helper.ts create mode 100644 src/app/core/fileuploader/services/fileuploader.ts create mode 100644 src/app/core/fileuploader/services/handlers/album.ts create mode 100644 src/app/core/fileuploader/services/handlers/audio.ts create mode 100644 src/app/core/fileuploader/services/handlers/camera.ts create mode 100644 src/app/core/fileuploader/services/handlers/file.ts create mode 100644 src/app/core/fileuploader/services/handlers/video.ts 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. +}; From fdada610205938647ea890e5bf35f2149ce7152e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 6 Nov 2020 15:34:01 +0100 Subject: [PATCH 5/9] MOBILE-3585 addon: Implement private files addon --- scripts/langindex.json | 12 +- src/app/addon/privatefiles/lang/en.json | 8 + .../addon/privatefiles/pages/index/index.html | 53 ++ .../pages/index/index.page.module.ts | 49 ++ .../privatefiles/pages/index/index.page.ts | 270 ++++++++++ .../privatefiles/privatefiles-init.module.ts | 45 ++ .../privatefiles-routing.module.ts | 34 ++ .../addon/privatefiles/privatefiles.module.ts | 25 + .../services/handlers/mainmenu.ts | 52 ++ .../services/privatefiles.helper.ts | 77 +++ .../privatefiles/services/privatefiles.ts | 497 ++++++++++++++++++ src/app/app.module.ts | 6 + src/app/core/login/pages/sites/sites.html | 16 +- src/app/services/utils/text.ts | 8 +- src/app/singletons/core.singletons.ts | 10 + src/theme/app.scss | 23 +- 16 files changed, 1166 insertions(+), 19 deletions(-) create mode 100644 src/app/addon/privatefiles/lang/en.json create mode 100644 src/app/addon/privatefiles/pages/index/index.html create mode 100644 src/app/addon/privatefiles/pages/index/index.page.module.ts create mode 100644 src/app/addon/privatefiles/pages/index/index.page.ts create mode 100644 src/app/addon/privatefiles/privatefiles-init.module.ts create mode 100644 src/app/addon/privatefiles/privatefiles-routing.module.ts create mode 100644 src/app/addon/privatefiles/privatefiles.module.ts create mode 100644 src/app/addon/privatefiles/services/handlers/mainmenu.ts create mode 100644 src/app/addon/privatefiles/services/privatefiles.helper.ts create mode 100644 src/app/addon/privatefiles/services/privatefiles.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index 1e1606b32..0833ae5d8 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -228,12 +228,6 @@ "addon.coursecompletion.requirement": "block_completionstatus", "addon.coursecompletion.status": "moodle", "addon.coursecompletion.viewcoursereport": "completion", - "addon.files.couldnotloadfiles": "local_moodlemobileapp", - "addon.files.emptyfilelist": "local_moodlemobileapp", - "addon.files.erroruploadnotworking": "local_moodlemobileapp", - "addon.files.files": "moodle", - "addon.files.privatefiles": "moodle", - "addon.files.sitefiles": "moodle", "addon.messageoutput_airnotifier.processorsettingsdesc": "local_moodlemobileapp", "addon.messages.acceptandaddcontact": "message", "addon.messages.addcontact": "message", @@ -1049,6 +1043,12 @@ "addon.notifications.notifications": "local_moodlemobileapp", "addon.notifications.playsound": "local_moodlemobileapp", "addon.notifications.therearentnotificationsyet": "local_moodlemobileapp", + "addon.privatefiles.couldnotloadfiles": "local_moodlemobileapp", + "addon.privatefiles.emptyfilelist": "local_moodlemobileapp", + "addon.privatefiles.erroruploadnotworking": "local_moodlemobileapp", + "addon.privatefiles.files": "moodle", + "addon.privatefiles.privatefiles": "moodle", + "addon.privatefiles.sitefiles": "moodle", "addon.storagemanager.deletecourse": "local_moodlemobileapp", "addon.storagemanager.deletecourses": "local_moodlemobileapp", "addon.storagemanager.deletedatafrom": "local_moodlemobileapp", diff --git a/src/app/addon/privatefiles/lang/en.json b/src/app/addon/privatefiles/lang/en.json new file mode 100644 index 000000000..b923f6141 --- /dev/null +++ b/src/app/addon/privatefiles/lang/en.json @@ -0,0 +1,8 @@ +{ + "couldnotloadfiles": "The list of files could not be loaded.", + "emptyfilelist": "There are no files to show.", + "erroruploadnotworking": "Unfortunately it is currently not possible to upload files to your site.", + "files": "Files", + "privatefiles": "Private files", + "sitefiles": "Site files" +} \ No newline at end of file diff --git a/src/app/addon/privatefiles/pages/index/index.html b/src/app/addon/privatefiles/pages/index/index.html new file mode 100644 index 000000000..e7aa95a36 --- /dev/null +++ b/src/app/addon/privatefiles/pages/index/index.html @@ -0,0 +1,53 @@ + + + + + + {{ title }} + + + + + + + + + +
+ + {{ 'addon.privatefiles.privatefiles' | translate }} + {{ 'addon.privatefiles.sitefiles' | translate }} + +
+ + +

+ {{ 'core.quotausage' | translate:{$a: {used: spaceUsed, total: userQuotaReadable} } }} +

+ + + + + + + + + {{file.filename}} + + + + + + + + +
+ + + + + + + +
\ No newline at end of file diff --git a/src/app/addon/privatefiles/pages/index/index.page.module.ts b/src/app/addon/privatefiles/pages/index/index.page.module.ts new file mode 100644 index 000000000..2c4e04763 --- /dev/null +++ b/src/app/addon/privatefiles/pages/index/index.page.module.ts @@ -0,0 +1,49 @@ +// (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 { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +import { AddonPrivateFilesIndexPage } from './index.page'; + +const routes: Routes = [ + { + path: '', + component: AddonPrivateFilesIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + FormsModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + AddonPrivateFilesIndexPage, + ], + exports: [RouterModule], +}) +export class AddonPrivateFilesIndexPageModule {} diff --git a/src/app/addon/privatefiles/pages/index/index.page.ts b/src/app/addon/privatefiles/pages/index/index.page.ts new file mode 100644 index 000000000..ca20a6df2 --- /dev/null +++ b/src/app/addon/privatefiles/pages/index/index.page.ts @@ -0,0 +1,270 @@ +// (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 { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { IonRefresher, NavController } from '@ionic/angular'; +import { Md5 } from 'ts-md5/dist/md5'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { Translate } from '@singletons/core.singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { + AddonPrivateFiles, + AddonPrivateFilesProvider, + AddonPrivateFilesFile, + AddonPrivateFilesGetUserInfoWSResult, + AddonPrivateFilesGetFilesWSParams, +} from '@addon/privatefiles/services/privatefiles'; +import { AddonPrivateFilesHelper } from '@addon/privatefiles/services/privatefiles.helper'; +import { CoreUtils } from '@/app/services/utils/utils'; + +/** + * Page that displays the list of files. + */ +@Component({ + selector: 'page-addon-privatefiles-index', + templateUrl: 'index.html', +}) +export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { + + title!: string; // Page title. + root?: 'my' | 'site'; // The root of the files loaded: 'my' or 'site'. + path?: AddonPrivateFilesGetFilesWSParams; // The path of the directory being loaded. If empty path it means load the root. + showPrivateFiles!: boolean; // Whether the user can view private files. + showSiteFiles!: boolean; // Whether the user can view site files. + showUpload!: boolean; // Whether the user can upload files. + userQuota?: number; // The user quota (in bytes). + filesInfo?: AddonPrivateFilesGetUserInfoWSResult; // Info about private files (size, number of files, etc.). + spaceUsed?: string; // Space used in a readable format. + userQuotaReadable?: string; // User quota in a readable format. + files?: AddonPrivateFilesFile[]; // List of files. + component!: string; // Component to link the file downloads to. + filesLoaded = false; // Whether the files are loaded. + + protected updateSiteObserver: CoreEventObserver; + + constructor( + protected route: ActivatedRoute, + protected navCtrl: NavController, + ) { + // Update visibility if current site info is updated. + this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + this.setVisibility(); + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.root = this.route.snapshot.queryParams['root']; + + if (this.route.snapshot.queryParams['contextid']) { + // Loading a certain folder. + this.path = { + contextid: this.route.snapshot.queryParams['contextid'], + component: this.route.snapshot.queryParams['component'], + filearea: this.route.snapshot.queryParams['filearea'], + itemid: this.route.snapshot.queryParams['itemid'], + filepath: this.route.snapshot.queryParams['filepath'], + filename: this.route.snapshot.queryParams['filename'], + }; + } + + this.title = this.path?.filename || Translate.instance.instant('addon.privatefiles.files'); + + this.setVisibility(); + this.userQuota = CoreSites.instance.getCurrentSite()?.getInfo()?.userquota; + + if (!this.root) { + // Load private files by default. + if (this.showPrivateFiles) { + this.root = 'my'; + } else if (this.showSiteFiles) { + this.root = 'site'; + } + } + + if (this.root) { + this.rootChanged(); + } else { + this.filesLoaded = true; + } + } + + /** + * Set visibility of some items based on site data. + */ + protected setVisibility(): void { + this.showPrivateFiles = AddonPrivateFiles.instance.canViewPrivateFiles(); + this.showSiteFiles = AddonPrivateFiles.instance.canViewSiteFiles(); + this.showUpload = AddonPrivateFiles.instance.canUploadFiles(); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + */ + refreshData(event?: CustomEvent): void { + this.refreshFiles().finally(() => { + event?.detail.complete(); + }); + } + + /** + * Function called when the root has changed. + */ + rootChanged(): void { + this.filesLoaded = false; + this.component = this.root == 'my' ? AddonPrivateFilesProvider.PRIVATE_FILES_COMPONENT : + AddonPrivateFilesProvider.SITE_FILES_COMPONENT; + + this.fetchFiles().finally(() => { + this.filesLoaded = true; + }); + } + + /** + * Upload a new file. + */ + async uploadFile(): Promise { + const canUpload = await AddonPrivateFiles.instance.versionCanUploadFiles(); + + if (!canUpload) { + CoreDomUtils.instance.showAlertTranslated('core.notice', 'addon.privatefiles.erroruploadnotworking'); + + return; + } + + if (!CoreApp.instance.isOnline()) { + CoreDomUtils.instance.showErrorModal('core.fileuploader.errormustbeonlinetoupload', true); + + return; + } + + try { + await AddonPrivateFilesHelper.instance.uploadPrivateFile(this.filesInfo); + + // File uploaded, refresh the list. + this.filesLoaded = false; + + await CoreUtils.instance.ignoreErrors(this.refreshFiles()); + + this.filesLoaded = true; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.fileuploader.errorwhileuploading', true); + } + } + + /** + * Fetch the files. + * + * @return Promise resolved when done. + */ + protected async fetchFiles(): Promise { + try { + if (this.path) { + // Path is set, serve the files the user requested. + this.files = await AddonPrivateFiles.instance.getFiles(this.path); + + return; + } + + // The path is unknown, the user must be requesting a root. + if (this.root == 'site') { + this.title = Translate.instance.instant('addon.privatefiles.sitefiles'); + + this.files = await AddonPrivateFiles.instance.getSiteFiles(); + } else if (this.root == 'my') { + this.title = Translate.instance.instant('addon.privatefiles.files'); + + this.files = await AddonPrivateFiles.instance.getPrivateFiles(); + + if (this.showUpload && AddonPrivateFiles.instance.canGetPrivateFilesInfo() && this.userQuota && + this.userQuota > 0) { + // Get the info to calculate the available size. + this.filesInfo = await AddonPrivateFiles.instance.getPrivateFilesInfo(); + + this.spaceUsed = CoreTextUtils.instance.bytesToSize(this.filesInfo.filesizewithoutreferences, 1); + this.userQuotaReadable = CoreTextUtils.instance.bytesToSize(this.userQuota, 1); + } else { + // User quota isn't useful, delete it. + delete this.userQuota; + } + } else { + // Unknown root. + CoreDomUtils.instance.showErrorModal('addon.privatefiles.couldnotloadfiles', true); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.privatefiles.couldnotloadfiles', true); + } + } + + /** + * Refresh the displayed files. + * + * @return Promise resolved when done. + */ + protected async refreshFiles(): Promise { + try { + await Promise.all([ + AddonPrivateFiles.instance.invalidateDirectory(this.root, this.path), + AddonPrivateFiles.instance.invalidatePrivateFilesInfoForUser(), + ]); + } finally { + await this.fetchFiles(); + } + } + + /** + * Open a folder. + * + * @param folder Folder to open. + */ + openFolder(folder: AddonPrivateFilesFile): void { + const params = { + contextid: folder.contextid, + component: folder.component || '', + filearea: folder.filearea || '', + itemid: folder.itemid || 0, + filepath: folder.filepath || '', + filename: folder.filename || '', + }; + + if (folder.component) { + // Delete unused elements that may break the request. + params.filename = ''; + } + + const hash = Md5.hashAsciiStr(JSON.stringify(params)); + + this.navCtrl.navigateForward([`../${hash}`], { + relativeTo: this.route, + queryParams: params, + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.updateSiteObserver?.off(); + } + +} diff --git a/src/app/addon/privatefiles/privatefiles-init.module.ts b/src/app/addon/privatefiles/privatefiles-init.module.ts new file mode 100644 index 000000000..b09af1fd2 --- /dev/null +++ b/src/app/addon/privatefiles/privatefiles-init.module.ts @@ -0,0 +1,45 @@ +// (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 { Routes } from '@angular/router'; + +import { CoreMainMenuDelegate } from '@core/mainmenu/services/mainmenu.delegate'; +import { CoreMainMenuRoutingModule } from '@core/mainmenu/mainmenu-routing.module'; +import { AddonPrivateFilesMainMenuHandler } from './services/handlers/mainmenu'; + +const routes: Routes = [ + { + path: 'addon-privatefiles', + loadChildren: () => import('@addon/privatefiles/privatefiles.module').then(m => m.AddonPrivateFilesModule), + }, +]; + +@NgModule({ + imports: [CoreMainMenuRoutingModule.forChild(routes)], + exports: [CoreMainMenuRoutingModule], + providers: [ + AddonPrivateFilesMainMenuHandler, + ], +}) +export class AddonPrivateFilesInitModule { + + constructor( + mainMenuDelegate: CoreMainMenuDelegate, + mainMenuHandler: AddonPrivateFilesMainMenuHandler, + ) { + mainMenuDelegate.registerHandler(mainMenuHandler); + } + +} diff --git a/src/app/addon/privatefiles/privatefiles-routing.module.ts b/src/app/addon/privatefiles/privatefiles-routing.module.ts new file mode 100644 index 000000000..97167e019 --- /dev/null +++ b/src/app/addon/privatefiles/privatefiles-routing.module.ts @@ -0,0 +1,34 @@ +// (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 { RouterModule, Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: '', + redirectTo: 'root', // Fake "hash". + pathMatch: 'full', + }, + { + path: ':hash', + loadChildren: () => import('./pages/index/index.page.module').then( m => m.AddonPrivateFilesIndexPageModule), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class AddonPrivateFilesRoutingModule {} diff --git a/src/app/addon/privatefiles/privatefiles.module.ts b/src/app/addon/privatefiles/privatefiles.module.ts new file mode 100644 index 000000000..3ddb6e41c --- /dev/null +++ b/src/app/addon/privatefiles/privatefiles.module.ts @@ -0,0 +1,25 @@ +// (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 { AddonPrivateFilesRoutingModule } from './privatefiles-routing.module'; + +@NgModule({ + imports: [ + AddonPrivateFilesRoutingModule, + ], + declarations: [], +}) +export class AddonPrivateFilesModule {} diff --git a/src/app/addon/privatefiles/services/handlers/mainmenu.ts b/src/app/addon/privatefiles/services/handlers/mainmenu.ts new file mode 100644 index 000000000..a858b611e --- /dev/null +++ b/src/app/addon/privatefiles/services/handlers/mainmenu.ts @@ -0,0 +1,52 @@ +// (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 { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@core/mainmenu/services/mainmenu.delegate'; +import { AddonPrivateFiles } from '@addon/privatefiles/services/privatefiles'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable() +export class AddonPrivateFilesMainMenuHandler implements CoreMainMenuHandler { + + name = 'AddonPrivateFiles'; + priority = 400; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return AddonPrivateFiles.instance.isPluginEnabled(); + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + getDisplayData(): CoreMainMenuHandlerData { + return { + icon: 'fa-folder', + title: 'addon.privatefiles.files', + page: 'addon-privatefiles', + class: 'addon-privatefiles-handler', + }; + } + +} diff --git a/src/app/addon/privatefiles/services/privatefiles.helper.ts b/src/app/addon/privatefiles/services/privatefiles.helper.ts new file mode 100644 index 000000000..2dbd3e5d9 --- /dev/null +++ b/src/app/addon/privatefiles/services/privatefiles.helper.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 { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreFileUploaderHelper } from '@core/fileuploader/services/fileuploader.helper'; +import { AddonPrivateFiles, AddonPrivateFilesGetUserInfoWSResult } from './privatefiles'; +import { CoreError } from '@classes/errors/error'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; + +/** + * Service that provides some helper functions regarding private and site files. + */ +@Injectable({ + providedIn: 'root', +}) +export class AddonPrivateFilesHelperProvider { + + /** + * Select a file, upload it and move it to private files. + * + * @param info Private files info. See AddonPrivateFilesProvider.getPrivateFilesInfo. + * @return Promise resolved when a file is uploaded, rejected otherwise. + */ + async uploadPrivateFile(info?: AddonPrivateFilesGetUserInfoWSResult): Promise { + // Calculate the max size. + const currentSite = CoreSites.instance.getCurrentSite(); + let maxSize = currentSite?.getInfo()?.usermaxuploadfilesize || -1; + let userQuota = currentSite?.getInfo()?.userquota; + + if (userQuota === 0) { + // 0 means ignore user quota. In the app it is -1. + userQuota = -1; + } else if (userQuota !== undefined && userQuota > 0 && info !== undefined) { + userQuota = userQuota - info.filesizewithoutreferences; + } + + if (userQuota !== undefined) { + // Use the minimum value. + maxSize = Math.min(maxSize, userQuota); + } + + // Select and upload the file. + const result = await CoreFileUploaderHelper.instance.selectAndUploadFile(maxSize); + + if (!result) { + throw new CoreError(Translate.instance.instant('core.fileuploader.errorwhileuploading')); + } + + // File uploaded. Move it to private files. + const modal = await CoreDomUtils.instance.showModalLoading('core.fileuploader.uploading', true); + + try { + await AddonPrivateFiles.instance.moveFromDraftToPrivate(result.itemid); + + CoreDomUtils.instance.showToast('core.fileuploader.fileuploaded', true, undefined, 'core-toast-success'); + } finally { + modal.dismiss(); + } + } + +} + +export class AddonPrivateFilesHelper extends makeSingleton(AddonPrivateFilesHelperProvider) {} diff --git a/src/app/addon/privatefiles/services/privatefiles.ts b/src/app/addon/privatefiles/services/privatefiles.ts new file mode 100644 index 000000000..e816bbd09 --- /dev/null +++ b/src/app/addon/privatefiles/services/privatefiles.ts @@ -0,0 +1,497 @@ +// (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 { CoreSites } from '@services/sites'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreWSExternalWarning } from '@services/ws'; +import { CoreSite } from '@classes/site'; +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Service to handle my files and site files. + */ +@Injectable({ + providedIn: 'root', +}) +export class AddonPrivateFilesProvider { + + // Keep old names for backwards compatibility. + static readonly PRIVATE_FILES_COMPONENT = 'mmaFilesMy'; + static readonly SITE_FILES_COMPONENT = 'mmaFilesSite'; + + protected readonly ROOT_CACHE_KEY = 'mmaFiles:'; + + /** + * Check if core_user_get_private_files_info WS call is available. + * + * @return Whether the WS is available, false otherwise. + */ + canGetPrivateFilesInfo(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_user_get_private_files_info'); + } + + /** + * Check if user can view his private files. + * + * @return Whether the user can view his private files. + */ + canViewPrivateFiles(): boolean { + const currentSite = CoreSites.instance.getCurrentSite(); + if (!currentSite) { + return false; + } + + return currentSite.canAccessMyFiles() && !this.isPrivateFilesDisabledInSite(); + } + + /** + * Check if user can view site files. + * + * @return Whether the user can view site files. + */ + canViewSiteFiles(): boolean { + return !this.isSiteFilesDisabledInSite(); + } + + /** + * Check if user can upload private files. + * + * @return Whether the user can upload private files. + */ + canUploadFiles(): boolean { + const currentSite = CoreSites.instance.getCurrentSite(); + if (!currentSite) { + return false; + } + + return currentSite.canAccessMyFiles() && currentSite.canUploadFiles() && !this.isUploadDisabledInSite(); + } + + /** + * Get the list of files. + * + * @param params A list of parameters accepted by the Web service. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getFiles(params: AddonPrivateFilesGetFilesWSParams, siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const preSets = { + cacheKey: this.getFilesListCacheKey(params), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + const result: AddonPrivateFilesGetFilesWSResult = await site.read('core_files_get_files', params, preSets); + + if (!result.files) { + return []; + } + + return result.files.map((entry) => { + entry.fileurl = entry.url; + + if (entry.isdir) { + entry.imgPath = CoreMimetypeUtils.instance.getFolderIcon(); + } else { + entry.imgPath = CoreMimetypeUtils.instance.getFileIcon(entry.filename); + } + + return entry; + }); + + } + + /** + * Get cache key for file list WS calls. + * + * @param params Params of the WS. + * @return Cache key. + */ + protected getFilesListCacheKey(params: AddonPrivateFilesGetFilesWSParams): string { + const root = !params.component ? 'site' : 'my'; + + return this.ROOT_CACHE_KEY + 'list:' + root + ':' + params.contextid + ':' + params.filepath; + } + + /** + * Get the private files of the current user. + * + * @return Promise resolved with the files. + */ + getPrivateFiles(): Promise { + return this.getFiles(this.getPrivateFilesRootParams()); + } + + /** + * Get params to get root private files directory. + * + * @return Params. + */ + protected getPrivateFilesRootParams(): AddonPrivateFilesGetFilesWSParams { + return { + contextid: -1, + component: 'user', + filearea: 'private', + contextlevel: 'user', + instanceid: CoreSites.instance.getCurrentSite()?.getUserId(), + itemid: 0, + filepath: '', + filename: '', + }; + } + + /** + * Get private files info. + * + * @param userId User ID. If not defined, current user in the site. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the info. + */ + async getPrivateFilesInfo(userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + const params: AddonPrivateFilesGetUserInfoWSParams = { + userid: userId, + }; + const preSets = { + cacheKey: this.getPrivateFilesInfoCacheKey(userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + return site.read('core_user_get_private_files_info', params, preSets); + } + + /** + * Get the cache key for private files info WS calls. + * + * @param userId User ID. + * @return Cache key. + */ + protected getPrivateFilesInfoCacheKey(userId: number): string { + return this.getPrivateFilesInfoCommonCacheKey() + ':' + userId; + } + + /** + * Get the common part of the cache keys for private files info WS calls. + * + * @return Cache key. + */ + protected getPrivateFilesInfoCommonCacheKey(): string { + return this.ROOT_CACHE_KEY + 'privateInfo'; + } + + /** + * Get the site files. + * + * @return Promise resolved with the files. + */ + getSiteFiles(): Promise { + return this.getFiles(this.getSiteFilesRootParams()); + } + + /** + * Get params to get root site files directory. + * + * @return Params. + */ + protected getSiteFilesRootParams(): AddonPrivateFilesGetFilesWSParams { + return { + contextid: 0, + component: '', + filearea: '', + itemid: 0, + filepath: '', + filename: '', + }; + } + + /** + * Invalidates list of files in a certain directory. + * + * @param root Root of the directory ('my' for private files, 'site' for site files). + * @param params Params to the directory. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDirectory(root?: 'my' | 'site', params?: AddonPrivateFilesGetFilesWSParams, siteId?: string): Promise { + if (!root) { + return; + } + + if (!params) { + if (root === 'site') { + params = this.getSiteFilesRootParams(); + } else { + params = this.getPrivateFilesRootParams(); + } + } + + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getFilesListCacheKey(params)); + } + + /** + * Invalidates private files info for all users. + * + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidatePrivateFilesInfo(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getPrivateFilesInfoCommonCacheKey()); + } + + /** + * Invalidates private files info for a certain user. + * + * @param userId User ID. If not defined, current user in the site. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidatePrivateFilesInfoForUser(userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getPrivateFilesInfoCacheKey(userId || site.getUserId())); + } + + /** + * Check if Files is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isDisabledInSite(site); + } + + /** + * Check if Files is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isDisabledInSite(site: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return site.isFeatureDisabled('CoreMainMenuDelegate_AddonPrivateFiles'); + } + + /** + * Return whether or not the plugin is enabled. + * + * @return True if enabled, false otherwise. + */ + isPluginEnabled(): boolean { + return this.canViewPrivateFiles() || this.canViewSiteFiles() || this.canUploadFiles(); + } + + /** + * Check if private files is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isPrivateFilesDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isPrivateFilesDisabledInSite(site); + } + + /** + * Check if private files is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isPrivateFilesDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isFeatureDisabled('AddonPrivateFilesPrivateFiles'); + } + + /** + * Check if site files is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isSiteFilesDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isSiteFilesDisabledInSite(site); + } + + /** + * Check if site files is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isSiteFilesDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isFeatureDisabled('AddonPrivateFilesSiteFiles'); + } + + /** + * Check if upload files is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isUploadDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isUploadDisabledInSite(site); + } + + /** + * Check if upload files is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isUploadDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isFeatureDisabled('AddonPrivateFilesUpload'); + } + + /** + * Move a file from draft area to private files. + * + * @param draftId The draft area ID of the file. + * @param siteid ID of the site. If not defined, use current site. + * @return Promise resolved in success, rejected otherwise. + */ + async moveFromDraftToPrivate(draftId: number, siteId?: string): Promise { + const params: AddonPrivateFilesAddUserPrivateFilesWSParams = { + draftid: draftId, + }; + const preSets = { + responseExpected: false, + }; + + const site = await CoreSites.instance.getSite(siteId); + + return site.write('core_user_add_user_private_files', params, preSets); + } + + /** + * Check the Moodle version in order to check if upload files is working. + * + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with true if WS is working, false otherwise. + */ + async versionCanUploadFiles(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Upload private files doesn't work for Moodle 3.1.0 due to a bug. + return site.isVersionGreaterEqualThan('3.1.1'); + } + +} + +export class AddonPrivateFiles extends makeSingleton(AddonPrivateFilesProvider) {} + +/** + * File data returned by core_files_get_files. + */ +export type AddonPrivateFilesFile = { + contextid: number; + component: string; + filearea: string; + itemid: number; + filepath: string; + filename: string; + isdir: boolean; + url: string; + timemodified: number; + timecreated?: number; // Time created. + filesize?: number; // File size. + author?: string; // File owner. + license?: string; // File license. +} & AddonPrivateFilesFileCalculatedData; + +/** + * Calculated data for AddonPrivateFilesFile. + */ +export type AddonPrivateFilesFileCalculatedData = { + fileurl: string; // File URL, using same name as CoreWSExternalFile. + imgPath?: string; // Path to file icon's image. +}; +/** + * Params of WS core_files_get_files. + */ +export type AddonPrivateFilesGetFilesWSParams = { + contextid: number; // Context id Set to -1 to use contextlevel and instanceid. + component: string; // Component. + filearea: string; // File area. + itemid: number; // Associated id. + filepath: string; // File path. + filename: string; // File name. + modified?: number; // Timestamp to return files changed after this time. + contextlevel?: string; // The context level for the file location. + instanceid?: number; // The instance id for where the file is located. +}; + +/** + * Result of WS core_files_get_files. + */ +export type AddonPrivateFilesGetFilesWSResult = { + parents: { + contextid: number; + component: string; + filearea: string; + itemid: number; + filepath: string; + filename: string; + }[]; + files: AddonPrivateFilesFile[]; +}; + +/** + * Params of core_user_get_private_files_info WS. + */ +export type AddonPrivateFilesGetUserInfoWSParams = { + userid?: number; // Id of the user, default to current user. +}; + +/** + * Data returned by core_user_get_private_files_info WS. + */ +export type AddonPrivateFilesGetUserInfoWSResult = { + filecount: number; // Number of files in the area. + foldercount: number; // Number of folders in the area. + filesize: number; // Total size of the files in the area. + filesizewithoutreferences: number; // Total size of the area excluding file references. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_user_add_user_private_files WS. + */ +export type AddonPrivateFilesAddUserPrivateFilesWSParams = { + draftid: number; // Draft area id. +}; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 1465a4fa9..953bc83ef 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -61,6 +61,10 @@ import { CoreEmulatorModule } from '@core/emulator/emulator.module'; import { CoreLoginModule } from '@core/login/login.module'; import { CoreCoursesModule } from '@core/courses/courses.module'; import { CoreSettingsInitModule } from '@core/settings/settings-init.module'; +import { CoreFileUploaderInitModule } from '@core/fileuploader/fileuploader-init.module'; + +// Import addons init modules. +import { AddonPrivateFilesInitModule } from '@addon/privatefiles/privatefiles-init.module'; import { setSingletonsInjector } from '@singletons/core.singletons'; @@ -91,6 +95,8 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { CoreLoginModule, CoreCoursesModule, CoreSettingsInitModule, + CoreFileUploaderInitModule, + AddonPrivateFilesInitModule, ], providers: [ { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, diff --git a/src/app/core/login/pages/sites/sites.html b/src/app/core/login/pages/sites/sites.html index 726b56252..bd544d273 100644 --- a/src/app/core/login/pages/sites/sites.html +++ b/src/app/core/login/pages/sites/sites.html @@ -33,12 +33,12 @@ - - - - - - - - + + + + + + + + diff --git a/src/app/services/utils/text.ts b/src/app/services/utils/text.ts index 2a3984044..cb96dd0e9 100644 --- a/src/app/services/utils/text.ts +++ b/src/app/services/utils/text.ts @@ -54,14 +54,14 @@ export class CoreTextUtilsProvider { { old: /_mmaNotifications/g, new: '_AddonNotifications' }, { old: /_mmaMessages/g, new: '_AddonMessages' }, { old: /_mmaCalendar/g, new: '_AddonCalendar' }, - { old: /_mmaFiles/g, new: '_AddonFiles' }, + { old: /_mmaFiles/g, new: '_AddonPrivateFiles' }, { old: /_mmaParticipants/g, new: '_CoreUserParticipants' }, { old: /_mmaCourseCompletion/g, new: '_AddonCourseCompletion' }, { old: /_mmaNotes/g, new: '_AddonNotes' }, { old: /_mmaBadges/g, new: '_AddonBadges' }, - { old: /files_privatefiles/g, new: 'AddonFilesPrivateFiles' }, - { old: /files_sitefiles/g, new: 'AddonFilesSiteFiles' }, - { old: /files_upload/g, new: 'AddonFilesUpload' }, + { old: /files_privatefiles/g, new: 'AddonPrivateFilesPrivateFiles' }, + { old: /files_sitefiles/g, new: 'AddonPrivateFilesSiteFiles' }, + { old: /files_upload/g, new: 'AddonPrivateFilesUpload' }, { old: /_mmaModAssign/g, new: '_AddonModAssign' }, { old: /_mmaModBook/g, new: '_AddonModBook' }, { old: /_mmaModChat/g, new: '_AddonModChat' }, diff --git a/src/app/singletons/core.singletons.ts b/src/app/singletons/core.singletons.ts index 561a8dd98..61c539be1 100644 --- a/src/app/singletons/core.singletons.ts +++ b/src/app/singletons/core.singletons.ts @@ -22,8 +22,11 @@ import { ModalController as ModalControllerService, ToastController as ToastControllerService, GestureController as GestureControllerService, + ActionSheetController as ActionSheetControllerService, } from '@ionic/angular'; +import { Camera as CameraService } from '@ionic-native/camera/ngx'; +import { Chooser as ChooserService } from '@ionic-native/chooser/ngx'; import { Clipboard as ClipboardService } from '@ionic-native/clipboard/ngx'; import { Diagnostic as DiagnosticService } from '@ionic-native/diagnostic/ngx'; import { Device as DeviceService } from '@ionic-native/device/ngx'; @@ -36,6 +39,8 @@ import { InAppBrowser as InAppBrowserService } from '@ionic-native/in-app-browse import { WebView as WebViewService } from '@ionic-native/ionic-webview/ngx'; import { Keyboard as KeyboardService } from '@ionic-native/keyboard/ngx'; import { LocalNotifications as LocalNotificationsService } from '@ionic-native/local-notifications/ngx'; +import { Media as MediaService } from '@ionic-native/media/ngx'; +import { MediaCapture as MediaCaptureService } from '@ionic-native/media-capture/ngx'; import { Network as NetworkService } from '@ionic-native/network/ngx'; import { Push as PushService } from '@ionic-native/push/ngx'; import { QRScanner as QRScannerService } from '@ionic-native/qr-scanner/ngx'; @@ -71,6 +76,8 @@ export function makeSingleton(injectionToken: CoreInjectionToken Date: Mon, 9 Nov 2020 08:50:24 +0100 Subject: [PATCH 6/9] MOBILE-3585 lint: Fix some lint errors and warnings --- .../emulator/components/capture-media/capture-media.html | 2 +- src/app/core/emulator/services/capture.helper.ts | 1 - src/app/core/login/pages/reconnect/reconnect.html | 8 ++++++-- src/app/core/mainmenu/pages/menu/menu.html | 6 ++++-- src/app/core/settings/settings-init.module.ts | 4 +--- src/app/services/utils/dom.ts | 2 +- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/app/core/emulator/components/capture-media/capture-media.html b/src/app/core/emulator/components/capture-media/capture-media.html index b2afae1dd..fcd74424e 100644 --- a/src/app/core/emulator/components/capture-media/capture-media.html +++ b/src/app/core/emulator/components/capture-media/capture-media.html @@ -1,6 +1,6 @@ - {{ title | translate }} + {{ title | translate }} {{ 'core.cancel' | translate }} diff --git a/src/app/core/emulator/services/capture.helper.ts b/src/app/core/emulator/services/capture.helper.ts index a8589a625..9df20ccaa 100644 --- a/src/app/core/emulator/services/capture.helper.ts +++ b/src/app/core/emulator/services/capture.helper.ts @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreCanceledError } from '@/app/classes/errors/cancelederror'; import { Injectable } from '@angular/core'; import { CameraOptions } from '@ionic-native/camera/ngx'; import { CaptureAudioOptions, CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx'; diff --git a/src/app/core/login/pages/reconnect/reconnect.html b/src/app/core/login/pages/reconnect/reconnect.html index f11aeb816..c1d18e233 100644 --- a/src/app/core/login/pages/reconnect/reconnect.html +++ b/src/app/core/login/pages/reconnect/reconnect.html @@ -49,10 +49,14 @@ - {{ 'core.login.cancel' | translate }} + + {{ 'core.login.cancel' | translate }} + - {{ 'core.login.loginbutton' | translate }} + + {{ 'core.login.loginbutton' | translate }} + diff --git a/src/app/core/mainmenu/pages/menu/menu.html b/src/app/core/mainmenu/pages/menu/menu.html index caa058462..65c5db9b6 100644 --- a/src/app/core/mainmenu/pages/menu/menu.html +++ b/src/app/core/mainmenu/pages/menu/menu.html @@ -2,9 +2,11 @@ - + + - + {{ tab.title | translate }} {{ tab.badge }} diff --git a/src/app/core/settings/settings-init.module.ts b/src/app/core/settings/settings-init.module.ts index 70c559c98..10b819776 100644 --- a/src/app/core/settings/settings-init.module.ts +++ b/src/app/core/settings/settings-init.module.ts @@ -16,7 +16,6 @@ import { NgModule } from '@angular/core'; import { Routes } from '@angular/router'; import { CoreMainMenuRoutingModule } from '@core/mainmenu/mainmenu-routing.module'; -import { CoreSettingsHelperProvider } from './services/settings.helper'; const routes: Routes = [ { @@ -37,12 +36,11 @@ const routes: Routes = [ CoreMainMenuRoutingModule, ], providers: [ - CoreSettingsHelperProvider, ], }) export class CoreSettingsInitModule { - constructor(settingsHelper: CoreSettingsHelperProvider) { + constructor() { // @todo // settingsHelper.initDomSettings(); } diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts index a7fb099bd..ec31a81bc 100644 --- a/src/app/services/utils/dom.ts +++ b/src/app/services/utils/dom.ts @@ -1537,7 +1537,7 @@ export class CoreDomUtilsProvider { * @return Promise resolved when modal presented. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - showTextareaPrompt(title: string, message: string, buttons: (string | any)[], placeholder?: string): Promise { + showTextareaPrompt(title: string, message: string, buttons: (string | unknown)[], placeholder?: string): Promise { // @todo return Promise.resolve(); } From 3d3890a70d05f2d61942482900b53d56ea8de86d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 10 Nov 2020 15:38:15 +0100 Subject: [PATCH 7/9] MOBILE-3585 mainmenu: Fix go to root when tab clicked --- .../privatefiles/services/handlers/mainmenu.ts | 1 + src/app/core/mainmenu/pages/menu/menu.page.ts | 16 ++++++++++++++-- .../core/mainmenu/services/handlers/mainmenu.ts | 1 + .../core/mainmenu/services/mainmenu.delegate.ts | 7 +++++++ src/app/services/utils/text.ts | 14 ++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/app/addon/privatefiles/services/handlers/mainmenu.ts b/src/app/addon/privatefiles/services/handlers/mainmenu.ts index a858b611e..61e8d6ce2 100644 --- a/src/app/addon/privatefiles/services/handlers/mainmenu.ts +++ b/src/app/addon/privatefiles/services/handlers/mainmenu.ts @@ -45,6 +45,7 @@ export class AddonPrivateFilesMainMenuHandler implements CoreMainMenuHandler { icon: 'fa-folder', title: 'addon.privatefiles.files', page: 'addon-privatefiles', + subPage: 'root', class: 'addon-privatefiles-handler', }; } diff --git a/src/app/core/mainmenu/pages/menu/menu.page.ts b/src/app/core/mainmenu/pages/menu/menu.page.ts index 48f6af306..68925539e 100644 --- a/src/app/core/mainmenu/pages/menu/menu.page.ts +++ b/src/app/core/mainmenu/pages/menu/menu.page.ts @@ -19,6 +19,7 @@ import { Subscription } from 'rxjs'; import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; import { CoreEvents, CoreEventObserver, CoreEventLoadPageMainMenuData } from '@singletons/events'; import { CoreMainMenu } from '../../services/mainmenu'; import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu.delegate'; @@ -242,8 +243,10 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { return; } + const trimmedUrl = CoreTextUtils.instance.trimCharacter(this.router.url, '/'); + // Current tab was clicked. Check if user is already at root level. - if (this.router.url == '/mainmenu/' + page) { + if (trimmedUrl == CoreTextUtils.instance.trimCharacter(page, '/')) { // Already at root level, nothing to do. return; } @@ -255,8 +258,17 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { try { const tab = this.tabs.find((tab) => tab.page == page); + // Use tab's subPage to check if user is already at root level. + if (tab?.subPage && trimmedUrl == + CoreTextUtils.instance.trimCharacter(CoreTextUtils.instance.concatenatePaths(tab.page, tab.subPage), '/')) { + // Already at root level, nothing to do. + return; + } + if (tab?.title) { - await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmgotabroot', { name: tab.title })); + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmgotabroot', { + name: Translate.instance.instant(tab.title), + })); } else { await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmgotabrootdefault')); } diff --git a/src/app/core/mainmenu/services/handlers/mainmenu.ts b/src/app/core/mainmenu/services/handlers/mainmenu.ts index 36a106e6a..3ca4bf6fd 100644 --- a/src/app/core/mainmenu/services/handlers/mainmenu.ts +++ b/src/app/core/mainmenu/services/handlers/mainmenu.ts @@ -55,6 +55,7 @@ export class CoreHomeMainMenuHandler implements CoreMainMenuHandler { icon: 'fa-home', title: 'core.mainmenu.home', page: 'home', + // @todo: subPage? The page can change due to core-tabs. class: 'core-home-handler', }; } diff --git a/src/app/core/mainmenu/services/mainmenu.delegate.ts b/src/app/core/mainmenu/services/mainmenu.delegate.ts index 2a0208749..e3847b168 100644 --- a/src/app/core/mainmenu/services/mainmenu.delegate.ts +++ b/src/app/core/mainmenu/services/mainmenu.delegate.ts @@ -32,6 +32,13 @@ export interface CoreMainMenuHandlerData { */ page: string; + /** + * Sub page loaded when the handler page is loaded. + * If your module performs a redirect when it's opened you need to specify the sub page in here. + * E.g. if page is 'foo' but it redirects to 'foo/bar' when opened, this value must be 'bar'. + */ + subPage?: string; + /** * Title to display for the handler. */ diff --git a/src/app/services/utils/text.ts b/src/app/services/utils/text.ts index cb96dd0e9..dcaf3c00f 100644 --- a/src/app/services/utils/text.ts +++ b/src/app/services/utils/text.ts @@ -891,6 +891,20 @@ export class CoreTextUtilsProvider { }); } + /** + * Remove all ocurrences of a certain character from the start and end of a string. + * + * @param text Text to treat. + * @param character Character to remove. + * @return Treated text. + */ + trimCharacter(text: string, character: string): string { + const escaped = this.escapeForRegex(character); + const regExp = new RegExp(`^${escaped}+|${escaped}+$`, 'g'); + + return text.replace(regExp, ''); + } + /** * If a number has only 1 digit, add a leading zero to it. * From f1af6e6a6e6b65547c554fab24958c1241e48ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 12 Nov 2020 12:10:51 +0100 Subject: [PATCH 8/9] MOBILE-3585 files: Fix styles on file media --- .../addon/privatefiles/pages/index/index.html | 10 +-- .../services/handlers/mainmenu.ts | 2 +- src/app/components/file/core-file.html | 2 +- src/app/components/loading/loading.ts | 9 ++- .../capture-media/capture-media.scss | 2 - src/theme/app.scss | 69 +++++++++++++++++++ 6 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/app/addon/privatefiles/pages/index/index.html b/src/app/addon/privatefiles/pages/index/index.html index e7aa95a36..cc6728c7b 100644 --- a/src/app/addon/privatefiles/pages/index/index.html +++ b/src/app/addon/privatefiles/pages/index/index.html @@ -22,14 +22,16 @@ -

+ + {{ 'core.quotausage' | translate:{$a: {used: spaceUsed, total: userQuotaReadable} } }} -

+ + - + @@ -50,4 +52,4 @@ - \ No newline at end of file + diff --git a/src/app/addon/privatefiles/services/handlers/mainmenu.ts b/src/app/addon/privatefiles/services/handlers/mainmenu.ts index 61e8d6ce2..d7c5c33d5 100644 --- a/src/app/addon/privatefiles/services/handlers/mainmenu.ts +++ b/src/app/addon/privatefiles/services/handlers/mainmenu.ts @@ -42,7 +42,7 @@ export class AddonPrivateFilesMainMenuHandler implements CoreMainMenuHandler { */ getDisplayData(): CoreMainMenuHandlerData { return { - icon: 'fa-folder', + icon: 'fas-folder', title: 'addon.privatefiles.files', page: 'addon-privatefiles', subPage: 'root', diff --git a/src/app/components/file/core-file.html b/src/app/components/file/core-file.html index 03401dbda..71d1798c4 100644 --- a/src/app/components/file/core-file.html +++ b/src/app/components/file/core-file.html @@ -1,4 +1,4 @@ - + diff --git a/src/app/components/loading/loading.ts b/src/app/components/loading/loading.ts index a255e52d7..7e2557888 100644 --- a/src/app/components/loading/loading.ts +++ b/src/app/components/loading/loading.ts @@ -95,11 +95,10 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit { setTimeout(() => { // Content is loaded so, center the spinner on the content itself. this.element.classList.add('core-loading-loaded'); - setTimeout(() => { - // Change CSS to force calculate height. - this.content?.nativeElement.classList.add('core-loading-content'); - this.content?.nativeElement.classList.remove('core-loading-content-loading'); - }, 500); + // Change CSS to force calculate height. + // Removed 500ms timeout to avoid reallocating html. + this.content?.nativeElement.classList.add('core-loading-content'); + this.content?.nativeElement.classList.remove('core-loading-content-loading'); }); } else { this.element.classList.remove('core-loading-loaded'); diff --git a/src/app/core/emulator/components/capture-media/capture-media.scss b/src/app/core/emulator/components/capture-media/capture-media.scss index 8e911e0b4..58abf90e7 100644 --- a/src/app/core/emulator/components/capture-media/capture-media.scss +++ b/src/app/core/emulator/components/capture-media/capture-media.scss @@ -1,6 +1,5 @@ :host { .core-av-wrapper { - // @todo: For some reason it takes a while to apply these styles, first it's displayed too big and then it's resized. width: 100%; height: 100%; @@ -63,7 +62,6 @@ } } - ion-footer { background-color: var(--gray); border-top: 1px solid var(--gray-dark); diff --git a/src/theme/app.scss b/src/theme/app.scss index 61da86718..568622430 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -112,6 +112,75 @@ ion-list.list-md { --ion-safe-area-left: calc(-1 * var(--selected-item-border-width)); } +.item.item-file { + ion-thumbnail { + --size: 32px; + width: var(--size); + height: var(--size); + } +} + +.item.core-primary-item, +.item.core-info-item, +.item.core-secondary-item, +.item.core-tertiary-item, +.item.core-success-item, +.item.core-warning-item, +.item.core-danger-item { + --inner-border-width: 0 0 3px 0; +} +.item.core-primary-item { + --border-color: var(--ion-color-primary); +} +.item.core-info-item, +.item.core-secondary-item { + --border-color: var(--ion-color-secondary); +} +.item.core-tertiary-item { + --border-color: var(--ion-color-tertiary); +} +.item.core-success-item { + --border-color: var(--ion-color-success); +} +.item.core-warning-item { + --border-color: var(--ion-color-warning); +} +.item.core-danger-item { + --border-color: var(--ion-color-danger); +} + +// Card styles + +// Message cards. +ion-card.core-primary-card, +ion-card.core-info-card, +ion-card.core-secondary-card, +ion-card.core-tertiary-card, +ion-card.core-success-card, +ion-card.core-warning-card, +ion-card.core-danger-card { + border-bottom: 3px solid transparent; +} +ion-card.core-primary-card { + border-bottom-color: var(--ion-color-primary); +} +ion-card.core-info-card, +ion-card.core-secondary-card { + border-bottom-color: var(--ion-color-secondary); +} +ion-card.core-tertiary-card { + border-bottom-color: var(--ion-color-tertiary); +} +ion-card.core-success-card { + border-bottom-color: var(--ion-color-success); +} +ion-card.core-warning-card { + border-bottom-color: var(--ion-color-warning); +} +ion-card.core-danger-card { + border-bottom-color: var(--ion-color-danger); +} + // Avatar // ------------------------- // Large centered avatar From e0f16ec49b83c190b7306704ad8bb8968cd3d599 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 12 Nov 2020 15:13:47 +0100 Subject: [PATCH 9/9] MOBILE-3585 core: Rename some helper files --- src/app/app.component.ts | 2 +- .../core/contentlinks/pages/choose-site/choose-site.page.ts | 2 +- src/app/core/contentlinks/services/contentlinks.helper.ts | 2 +- src/app/core/emulator/emulator.module.ts | 5 +---- src/app/core/emulator/services/capture.helper.ts | 4 +++- .../core/emulator/services/{helper.ts => emulator.helper.ts} | 4 +++- src/app/core/login/components/site-help/site-help.ts | 2 +- .../core/login/components/site-onboarding/site-onboarding.ts | 2 +- .../core/login/pages/change-password/change-password.page.ts | 2 +- src/app/core/login/pages/credentials/credentials.page.ts | 2 +- src/app/core/login/pages/email-signup/email-signup.page.ts | 2 +- .../pages/forgotten-password/forgotten-password.page.ts | 2 +- src/app/core/login/pages/init/init.page.ts | 2 +- src/app/core/login/pages/reconnect/reconnect.page.ts | 2 +- src/app/core/login/pages/site-policy/site-policy.page.ts | 2 +- src/app/core/login/pages/site/site.page.ts | 2 +- src/app/core/login/pages/sites/sites.page.ts | 2 +- src/app/core/login/services/{helper.ts => login.helper.ts} | 0 src/app/core/mainmenu/pages/more/more.page.ts | 2 +- 19 files changed, 22 insertions(+), 21 deletions(-) rename src/app/core/emulator/services/{helper.ts => emulator.helper.ts} (97%) rename src/app/core/login/services/{helper.ts => login.helper.ts} (100%) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2af23abcd..ce97f3c93 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -16,7 +16,7 @@ import { Component, OnInit } from '@angular/core'; import { NavController } from '@ionic/angular'; import { CoreLangProvider } from '@services/lang'; -import { CoreLoginHelperProvider } from '@core/login/services/helper'; +import { CoreLoginHelperProvider } from '@core/login/services/login.helper'; import { CoreEvents, CoreEventSessionExpiredData } from '@singletons/events'; import { Network, NgZone, Platform } from '@singletons/core.singletons'; import { CoreApp } from '@services/app'; diff --git a/src/app/core/contentlinks/pages/choose-site/choose-site.page.ts b/src/app/core/contentlinks/pages/choose-site/choose-site.page.ts index d5802ab1c..ebc2b9a95 100644 --- a/src/app/core/contentlinks/pages/choose-site/choose-site.page.ts +++ b/src/app/core/contentlinks/pages/choose-site/choose-site.page.ts @@ -17,7 +17,7 @@ import { NavController } from '@ionic/angular'; import { CoreSiteBasicInfo, CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { Translate } from '@singletons/core.singletons'; -import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreLoginHelper } from '@core/login/services/login.helper'; import { CoreContentLinksAction } from '../../services/contentlinks.delegate'; import { CoreContentLinksHelper } from '../../services/contentlinks.helper'; import { ActivatedRoute } from '@angular/router'; diff --git a/src/app/core/contentlinks/services/contentlinks.helper.ts b/src/app/core/contentlinks/services/contentlinks.helper.ts index ab6ab9cbc..dd9b835c3 100644 --- a/src/app/core/contentlinks/services/contentlinks.helper.ts +++ b/src/app/core/contentlinks/services/contentlinks.helper.ts @@ -17,7 +17,7 @@ import { NavController } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; -import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreLoginHelper } from '@core/login/services/login.helper'; import { CoreContentLinksDelegate, CoreContentLinksAction } from './contentlinks.delegate'; import { CoreSite } from '@classes/site'; import { CoreMainMenu } from '@core/mainmenu/services/mainmenu'; diff --git a/src/app/core/emulator/emulator.module.ts b/src/app/core/emulator/emulator.module.ts index db41f24f5..03efe389b 100644 --- a/src/app/core/emulator/emulator.module.ts +++ b/src/app/core/emulator/emulator.module.ts @@ -16,8 +16,7 @@ import { NgModule } from '@angular/core'; import { Platform } from '@ionic/angular'; import { CoreInitDelegate } from '@services/init'; -import { CoreEmulatorHelperProvider } from './services/helper'; -import { CoreEmulatorCaptureHelperProvider } from './services/capture.helper'; +import { CoreEmulatorHelperProvider } from './services/emulator.helper'; import { CoreEmulatorComponentsModule } from './components/components.module'; // Ionic Native services. @@ -74,8 +73,6 @@ import { ZipMock } from './services/zip'; CoreEmulatorComponentsModule, ], providers: [ - CoreEmulatorHelperProvider, - CoreEmulatorCaptureHelperProvider, { provide: Camera, deps: [Platform], diff --git a/src/app/core/emulator/services/capture.helper.ts b/src/app/core/emulator/services/capture.helper.ts index 9df20ccaa..89a3fab64 100644 --- a/src/app/core/emulator/services/capture.helper.ts +++ b/src/app/core/emulator/services/capture.helper.ts @@ -23,7 +23,9 @@ import { CaptureMediaComponentInputs, CoreEmulatorCaptureMediaComponent } from ' /** * Helper service with some features to capture media (image, audio, video). */ -@Injectable() +@Injectable({ + providedIn: 'root', +}) export class CoreEmulatorCaptureHelperProvider { protected possibleAudioMimeTypes = { diff --git a/src/app/core/emulator/services/helper.ts b/src/app/core/emulator/services/emulator.helper.ts similarity index 97% rename from src/app/core/emulator/services/helper.ts rename to src/app/core/emulator/services/emulator.helper.ts index b5c8c17e6..3e3f97ef5 100644 --- a/src/app/core/emulator/services/helper.ts +++ b/src/app/core/emulator/services/emulator.helper.ts @@ -25,7 +25,9 @@ import { FileTransferErrorMock } from './file-transfer'; /** * Helper service for the emulator feature. It also acts as an init handler. */ -@Injectable() +@Injectable({ + providedIn: 'root', +}) export class CoreEmulatorHelperProvider implements CoreInitHandler { name = 'CoreEmulator'; diff --git a/src/app/core/login/components/site-help/site-help.ts b/src/app/core/login/components/site-help/site-help.ts index 1cd9030ae..0a99681bd 100644 --- a/src/app/core/login/components/site-help/site-help.ts +++ b/src/app/core/login/components/site-help/site-help.ts @@ -16,7 +16,7 @@ import { Component } from '@angular/core'; import { CoreUtils } from '@services/utils/utils'; import { ModalController, Translate } from '@singletons/core.singletons'; -import { CoreLoginHelperProvider } from '@core/login/services/helper'; +import { CoreLoginHelperProvider } from '@core/login/services/login.helper'; /** * Component that displays help to connect to a site. diff --git a/src/app/core/login/components/site-onboarding/site-onboarding.ts b/src/app/core/login/components/site-onboarding/site-onboarding.ts index 29e568f4e..d9d6d6144 100644 --- a/src/app/core/login/components/site-onboarding/site-onboarding.ts +++ b/src/app/core/login/components/site-onboarding/site-onboarding.ts @@ -16,7 +16,7 @@ import { Component } from '@angular/core'; import { CoreConfig } from '@services/config'; import { CoreUtils } from '@services/utils/utils'; -import { CoreLoginHelperProvider } from '@core/login/services/helper'; +import { CoreLoginHelperProvider } from '@core/login/services/login.helper'; import { ModalController } from '@singletons/core.singletons'; /** diff --git a/src/app/core/login/pages/change-password/change-password.page.ts b/src/app/core/login/pages/change-password/change-password.page.ts index b3ae64d02..88564ff77 100644 --- a/src/app/core/login/pages/change-password/change-password.page.ts +++ b/src/app/core/login/pages/change-password/change-password.page.ts @@ -16,7 +16,7 @@ import { Component } from '@angular/core'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreLoginHelper } from '@core/login/services/login.helper'; import { Translate } from '@singletons/core.singletons'; /** diff --git a/src/app/core/login/pages/credentials/credentials.page.ts b/src/app/core/login/pages/credentials/credentials.page.ts index 3049a0245..9d0f92491 100644 --- a/src/app/core/login/pages/credentials/credentials.page.ts +++ b/src/app/core/login/pages/credentials/credentials.page.ts @@ -21,7 +21,7 @@ import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; -import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/helper'; +import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/login.helper'; import { CoreConstants } from '@core/constants'; import { Translate } from '@singletons/core.singletons'; import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes/site'; diff --git a/src/app/core/login/pages/email-signup/email-signup.page.ts b/src/app/core/login/pages/email-signup/email-signup.page.ts index e0bac5546..1aff70a6d 100644 --- a/src/app/core/login/pages/email-signup/email-signup.page.ts +++ b/src/app/core/login/pages/email-signup/email-signup.page.ts @@ -22,7 +22,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreCountry, CoreUtils } from '@services/utils/utils'; import { CoreWS, CoreWSExternalWarning } from '@services/ws'; -import { AuthEmailSignupProfileFieldsCategory, AuthEmailSignupSettings, CoreLoginHelper } from '@core/login/services/helper'; +import { AuthEmailSignupProfileFieldsCategory, AuthEmailSignupSettings, CoreLoginHelper } from '@core/login/services/login.helper'; import { CoreConstants } from '@core/constants'; import { Translate } from '@singletons/core.singletons'; import { CoreSitePublicConfigResponse } from '@classes/site'; diff --git a/src/app/core/login/pages/forgotten-password/forgotten-password.page.ts b/src/app/core/login/pages/forgotten-password/forgotten-password.page.ts index 8e0c357a0..ffb021905 100644 --- a/src/app/core/login/pages/forgotten-password/forgotten-password.page.ts +++ b/src/app/core/login/pages/forgotten-password/forgotten-password.page.ts @@ -18,7 +18,7 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { NavController } from '@ionic/angular'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreLoginHelper } from '@core/login/services/login.helper'; import { Translate, Platform } from '@singletons/core.singletons'; import { CoreWSExternalWarning } from '@services/ws'; diff --git a/src/app/core/login/pages/init/init.page.ts b/src/app/core/login/pages/init/init.page.ts index 51b7dd141..936d8dcb2 100644 --- a/src/app/core/login/pages/init/init.page.ts +++ b/src/app/core/login/pages/init/init.page.ts @@ -20,7 +20,7 @@ import { CoreInit } from '@services/init'; import { SplashScreen } from '@singletons/core.singletons'; import { CoreConstants } from '@core/constants'; import { CoreSites } from '@/app/services/sites'; -import { CoreLoginHelper } from '@/app/core/login/services/helper'; +import { CoreLoginHelper } from '@/app/core/login/services/login.helper'; /** * Page that displays a "splash screen" while the app is being initialized. diff --git a/src/app/core/login/pages/reconnect/reconnect.page.ts b/src/app/core/login/pages/reconnect/reconnect.page.ts index 3aea87e3b..449939627 100644 --- a/src/app/core/login/pages/reconnect/reconnect.page.ts +++ b/src/app/core/login/pages/reconnect/reconnect.page.ts @@ -21,7 +21,7 @@ import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; -import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreLoginHelper } from '@core/login/services/login.helper'; import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes/site'; import { CoreEvents } from '@singletons/events'; import { CoreError } from '@classes/errors/error'; diff --git a/src/app/core/login/pages/site-policy/site-policy.page.ts b/src/app/core/login/pages/site-policy/site-policy.page.ts index ca4858feb..4a5f87f1f 100644 --- a/src/app/core/login/pages/site-policy/site-policy.page.ts +++ b/src/app/core/login/pages/site-policy/site-policy.page.ts @@ -20,7 +20,7 @@ import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; -import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreLoginHelper } from '@core/login/services/login.helper'; import { CoreSite } from '@classes/site'; /** diff --git a/src/app/core/login/pages/site/site.page.ts b/src/app/core/login/pages/site/site.page.ts index 9ef042fd1..7470dca99 100644 --- a/src/app/core/login/pages/site/site.page.ts +++ b/src/app/core/login/pages/site/site.page.ts @@ -22,7 +22,7 @@ import { CoreConfig } from '@services/config'; import { CoreSites, CoreSiteCheckResponse, CoreLoginSiteInfo, CoreSitesDemoSiteData } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/helper'; +import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/login.helper'; import { CoreSite } from '@classes/site'; import { CoreError } from '@classes/errors/error'; import { CoreConstants } from '@core/constants'; diff --git a/src/app/core/login/pages/sites/sites.page.ts b/src/app/core/login/pages/sites/sites.page.ts index c05b8974f..fb1e8e632 100644 --- a/src/app/core/login/pages/sites/sites.page.ts +++ b/src/app/core/login/pages/sites/sites.page.ts @@ -18,7 +18,7 @@ import { Component, OnInit } from '@angular/core'; import { CoreSiteBasicInfo, CoreSites } from '@services/sites'; import { CoreLogger } from '@singletons/logger'; -import { CoreLoginHelper } from '../../services/helper'; +import { CoreLoginHelper } from '@core/login/services/login.helper'; /** * Page that displays a "splash screen" while the app is being initialized. diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/login.helper.ts similarity index 100% rename from src/app/core/login/services/helper.ts rename to src/app/core/login/services/login.helper.ts diff --git a/src/app/core/mainmenu/pages/more/more.page.ts b/src/app/core/mainmenu/pages/more/more.page.ts index 43f77bc8e..50c2028eb 100644 --- a/src/app/core/mainmenu/pages/more/more.page.ts +++ b/src/app/core/mainmenu/pages/more/more.page.ts @@ -18,7 +18,7 @@ import { Subscription } from 'rxjs'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreSiteInfo } from '@classes/site'; -import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreLoginHelper } from '@core/login/services/login.helper'; import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../services/mainmenu.delegate'; import { CoreMainMenu, CoreMainMenuCustomItem } from '../../services/mainmenu'; import { CoreEventObserver, CoreEvents } from '@singletons/events';