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 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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);
+ }
+
+}