MOBILE-3585 emulator: Add mocks of media services

main
Dani Palou 2020-11-06 15:20:28 +01:00
parent f581dbcc7c
commit 4bb7f0e97f
11 changed files with 1213 additions and 0 deletions

60
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;
}
}

View File

@ -0,0 +1,66 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ title | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="cancel()">{{ 'core.cancel' | translate }}</ion-button>
<ion-button *ngIf="hasCaptured" (click)="done()">{{ 'core.done' | translate }}</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="readyToCapture">
<div class="core-av-wrapper">
<!-- Video stream for image and video. -->
<video *ngIf="!isAudio" [hidden]="hasCaptured" class="core-webcam-stream" autoplay #streamVideo></video>
<!-- For video recording, use 2 videos and show/hide them because a CSS rule caused problems with the controls. -->
<video *ngIf="isVideo" [hidden]="!hasCaptured" class="core-webcam-video-captured" controls #previewVideo></video>
<!-- Canvas to treat the image and an img to show the result. -->
<canvas *ngIf="isImage" class="core-webcam-image-canvas" #imgCanvas></canvas>
<img *ngIf="isImage" [hidden]="!hasCaptured" class="core-webcam-image" alt="{{ 'core.capturedimage' | translate }}"
#previewImage>
<!-- Recording audio. -->
<div *ngIf="isAudio" class="core-audio-record-container">
<!-- Canvas to show audio waves when recording audio in browser. -->
<canvas [hidden]="hasCaptured || isCordovaAudioCapture" class="core-audio-canvas" #streamAudio></canvas>
<!-- Button to start/stop in mobile devices. -->
<ion-button fill="clear" *ngIf="!hasCaptured && isCordovaAudioCapture" (click)="actionClicked()"
[attr.aria-label]="title">
<ion-icon *ngIf="!isCapturing" name="fa-microphone" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isCapturing" name="fa-square" slot="icon-only"></ion-icon>
</ion-button>
<!-- Audio player to listen to the result. -->
<audio [hidden]="!hasCaptured" class="core-audio-captured" controls #previewAudio></audio>
</div>
</div>
</core-loading>
</ion-content>
<ion-footer *ngIf="readyToCapture">
<ion-row>
<ion-col></ion-col>
<ion-col class="ion-text-center">
<ion-button fill="clear" *ngIf="!hasCaptured && !isCordovaAudioCapture" (click)="actionClicked()"
[attr.aria-label]="title">
<ion-icon *ngIf="!isCapturing && isAudio" name="fa-microphone" slot="icon-only"></ion-icon>
<ion-icon *ngIf="!isCapturing && isVideo" name="fa-video" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isImage" name="fa-camera" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isCapturing" name="fa-square" slot="icon-only"></ion-icon>
</ion-button>
<ion-button fill="clear" *ngIf="hasCaptured" (click)="discard()" [attr.aria-label]="'core.discard' | translate">
<ion-icon color="danger" name="fa-trash" slot="icon-only"></ion-icon>
</ion-button>
</ion-col>
<ion-col class="ion-padding ion-text-end chrono-container">
<core-chrono *ngIf="!isImage" [hidden]="hasCaptured" [running]="isCapturing" [reset]="resetChrono" [endTime]="maxTime"
(onEnd)="stopCapturing()">
</core-chrono>
</ion-col>
</ion-row>
</ion-footer>

View File

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

View File

@ -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<void> {
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<void> {
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 || (<any> 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<void> {
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<void> {
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<void> {
if (this.returnDataUrl) {
// Return the image as a base64 string.
this.dismissWithData((<HTMLCanvasElement> 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.
};

View File

@ -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 {}

View File

@ -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],

View File

@ -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<any> {
// 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<string> {
return CoreEmulatorCaptureHelper.instance.captureMedia('image', options);
}
}

View File

@ -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<string>;
captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise<MediaFile[]>;
captureMedia(type: 'audio', options?: MockCaptureAudioOptions): Promise<MediaFile[]>;
captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise<MediaFile[]>;
async captureMedia(
type: 'image' | 'captureimage' | 'audio' | 'video',
options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureAudioOptions | MockCaptureVideoOptions,
): Promise<MediaFile[] | string> {
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<void> {
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.
}

View File

@ -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<MediaFile[]> {
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<MediaFile[]> {
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<MediaFile[]> {
return CoreEmulatorCaptureHelper.instance.captureMedia('video', options);
}
}