Merge pull request #4253 from dpalou/MOBILE-4670

Mobile 4670
main
Pau Ferrer Ocaña 2024-12-10 15:41:35 +01:00 committed by GitHub
commit 99e1223616
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 653 additions and 132 deletions

View File

@ -1571,6 +1571,7 @@
"core.confirmopeninbrowser": "local_moodlemobileapp",
"core.confirmremoveselectedfile": "local_moodlemobileapp",
"core.confirmremoveselectedfiles": "local_moodlemobileapp",
"core.connectandtryagain": "local_moodlemobileapp",
"core.connectionlost": "local_moodlemobileapp",
"core.considereddigitalminor": "moodle",
"core.contactsupport": "local_moodlemobileapp",
@ -1588,11 +1589,14 @@
"core.copytoclipboard": "local_moodlemobileapp",
"core.course": "moodle",
"core.course.activitydisabled": "local_moodlemobileapp",
"core.course.activitynotavailableoffline": "local_moodlemobileapp",
"core.course.activitynotyetviewableremoteaddon": "local_moodlemobileapp",
"core.course.activityrequiresconnection": "local_moodlemobileapp",
"core.course.allsections": "local_moodlemobileapp",
"core.course.aria:sectionprogress": "local_moodlemobileapp",
"core.course.availablespace": "local_moodlemobileapp",
"core.course.cannotdeletewhiledownloading": "local_moodlemobileapp",
"core.course.changesofflinemaybelost": "local_moodlemobileapp",
"core.course.communicationroomlink": "course",
"core.course.completion_automatic:done": "course",
"core.course.completion_automatic:failed": "course",
@ -2269,6 +2273,7 @@
"core.mygroups": "group",
"core.name": "moodle",
"core.needhelp": "local_moodlemobileapp",
"core.needinternettoaccessit": "local_moodlemobileapp",
"core.networkerroriframemsg": "local_moodlemobileapp",
"core.networkerrormsg": "local_moodlemobileapp",
"core.never": "moodle",

View File

@ -27,15 +27,21 @@
<core-course-module-info [module]="module" [description]="description" [component]="component" [componentId]="componentId"
[courseId]="courseId" [hasDataToSync]="hasOffline" (completionChanged)="onCompletionChange()" />
<!-- Offline disabled. -->
<ion-card class="core-warning-card" *ngIf="!siteCanDownload && playing">
<!-- User tried to play in offline a package that must be played in online. -->
@if (triedToPlay && !isOnline && (!siteCanDownload || hasMissingDependencies)) {
<ion-card class="core-warning-card">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
<ion-label>
@if (!siteCanDownload) {
{{ 'core.h5p.offlinedisabled' | translate }} {{ 'addon.mod_h5pactivity.offlinedisabledwarning' | translate }}
} @else {
{{ 'core.course.activitynotavailableoffline' | translate }} {{ 'core.needinternettoaccessit' | translate }}
}
</ion-label>
</ion-item>
</ion-card>
}
<!-- Preview mode. -->
<ion-card class="core-warning-card" *ngIf="accessInfo && !trackComponent">
@ -69,7 +75,7 @@
<core-h5p-iframe *ngIf="playing" [fileUrl]="fileUrl" [displayOptions]="displayOptions" [onlinePlayerUrl]="onlinePlayerUrl"
[trackComponent]="trackComponent" [contextId]="h5pActivity?.context" [enableInAppFullscreen]="true" [saveFreq]="saveFreq"
[state]="contentState" />
[state]="contentState" [component]="component" [componentId]="componentId" [fileTimemodified]="deployedFile?.timemodified" />
</core-loading>
<core-course-module-navigation collapsible-footer [hidden]="showLoading" [courseId]="courseId" [currentModuleId]="module.id" />

View File

@ -53,6 +53,11 @@ import {
ADDON_MOD_H5PACTIVITY_STATE_ID,
ADDON_MOD_H5PACTIVITY_TRACK_COMPONENT,
} from '../../constants';
import { CoreH5PMissingDependenciesError } from '@features/h5p/classes/errors/missing-dependencies-error';
import { CoreToasts, ToastDuration } from '@services/toasts';
import { Subscription } from 'rxjs';
import { NgZone, Translate } from '@singletons';
import { CoreError } from '@classes/errors/error';
/**
* Component that displays an H5P activity entry page.
@ -89,8 +94,11 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
isOpeningPage = false;
canViewAllAttempts = false;
saveStateEnabled = false;
hasMissingDependencies = false;
saveFreq?: number;
contentState?: string;
isOnline: boolean;
triedToPlay = false;
protected fetchContentDefaultError = 'addon.mod_h5pactivity.errorgetactivity';
protected syncEventName = ADDON_MOD_H5PACTIVITY_AUTO_SYNCED;
@ -98,6 +106,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
protected observer?: CoreEventObserver;
protected messageListenerFunction: (event: MessageEvent) => Promise<void>;
protected checkCompletionAfterLog = false; // It's called later, when the user plays the package.
protected onlineObserver: Subscription;
constructor(
protected content?: IonContent,
@ -111,6 +120,39 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
// Listen for messages from the iframe.
this.messageListenerFunction = (event) => this.onIframeMessage(event);
window.addEventListener('message', this.messageListenerFunction);
this.isOnline = CoreNetwork.isOnline();
this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => {
this.networkChanged();
});
});
}
/**
* React to a network status change.
*/
protected networkChanged(): void {
const wasOnline = this.isOnline;
this.isOnline = CoreNetwork.isOnline();
if (this.playing && !this.fileUrl && !this.isOnline && wasOnline && this.trackComponent) {
// User lost connection while playing an online package with tracking. Show an error.
CoreDomUtils.showErrorModal(new CoreError(Translate.instant('core.course.changesofflinemaybelost'), {
title: Translate.instant('core.youreoffline'),
}));
return;
}
if (this.isOnline && this.triedToPlay) {
// User couldn't play the package because he was offline, but he reconnected. Try again.
this.triedToPlay = false;
this.play();
return;
}
}
/**
@ -164,7 +206,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
);
}
if (!this.siteCanDownload || this.state === DownloadStatus.DOWNLOADED) {
if (!this.siteCanDownload || this.state === DownloadStatus.DOWNLOADED || this.hasMissingDependencies) {
// Cannot download the file or already downloaded, play the package directly.
this.play();
@ -219,12 +261,18 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
return;
}
this.deployedFile = await AddonModH5PActivity.getDeployedFile(this.h5pActivity, {
const deployedFile = await AddonModH5PActivity.getDeployedFile(this.h5pActivity, {
displayOptions: this.displayOptions,
siteId: this.siteId,
});
this.fileUrl = CoreFileHelper.getFileUrl(this.deployedFile);
this.hasMissingDependencies = await AddonModH5PActivity.hasMissingDependencies(this.module.id, deployedFile);
if (this.hasMissingDependencies) {
return;
}
this.deployedFile = deployedFile;
this.fileUrl = CoreFileHelper.getFileUrl(deployedFile);
// Listen for changes in the state.
const eventName = await CoreFilepool.getFileEventNameByUrl(this.site.getId(), this.fileUrl);
@ -362,6 +410,20 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
return;
}
if (error instanceof CoreH5PMissingDependenciesError) {
// Cannot be played offline, use online player.
this.hasMissingDependencies = true;
this.fileUrl = undefined;
this.play();
CoreToasts.show({
message: Translate.instant('core.course.activityrequiresconnection'),
duration: ToastDuration.LONG,
});
return;
}
CoreDomUtils.showErrorModalDefault(error, 'core.errordownloading', true);
}
}
@ -448,6 +510,16 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
return;
}
if (!this.fileUrl && !this.isOnline) {
this.triedToPlay = true;
CoreDomUtils.showErrorModal(new CoreError(Translate.instant('core.connectandtryagain'), {
title: Translate.instant('core.course.activitynotavailableoffline'),
}));
return;
}
this.playing = true;
// Mark the activity as viewed.
@ -456,6 +528,11 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
this.checkCompletion();
this.analyticsLogEvent('mod_h5pactivity_view_h5pactivity');
if (!this.fileUrl && this.trackComponent) {
// User is playing the package in online, invalidate attempts to fetch latest data.
AddonModH5PActivity.invalidateUserAttempts(this.h5pActivity.id, CoreSites.getCurrentSiteUserId());
}
}
/**
@ -466,6 +543,11 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
const userId = CoreSites.getCurrentSiteUserId();
try {
if (!this.fileUrl && this.trackComponent && this.h5pActivity) {
// User is playing the package in online, invalidate attempts to fetch latest data.
await AddonModH5PActivity.invalidateUserAttempts(this.h5pActivity.id, CoreSites.getCurrentSiteUserId());
}
await CoreNavigator.navigateToSitePath(
`${ADDON_MOD_H5PACTIVITY_PAGE_NAME}/${this.courseId}/${this.module.id}/userattempts/${userId}`,
);
@ -732,6 +814,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
super.ngOnDestroy();
this.observer?.off();
this.onlineObserver.unsubscribe();
// Wait a bit to make sure all messages have been received.
setTimeout(() => {

View File

@ -32,6 +32,9 @@ import {
AddonModH5PActivityGradeMethod,
} from '../constants';
import { CoreCacheUpdateFrequency } from '@/core/constants';
import { CoreFileHelper } from '@services/file-helper';
import { CorePromiseUtils } from '@singletons/promise-utils';
import { CoreH5PMissingDependencyDBRecord } from '@features/h5p/services/database/h5p';
/**
* Service that provides some features for H5P activity.
@ -571,6 +574,45 @@ export class AddonModH5PActivityProvider {
return this.getH5PActivityByField(courseId, 'id', id, options);
}
/**
* Get missing dependencies for a certain H5P activity.
*
* @param componentId Component ID.
* @param deployedFile File to check.
* @param siteId Site ID. If not defined, current site.
* @returns Missing dependencies, empty if no missing dependencies.
*/
async getMissingDependencies(
componentId: number,
deployedFile: CoreWSFile,
siteId?: string,
): Promise<CoreH5PMissingDependencyDBRecord[]> {
const fileUrl = CoreFileHelper.getFileUrl(deployedFile);
const missingDependencies =
await CoreH5P.h5pFramework.getMissingDependenciesForComponent(ADDON_MOD_H5PACTIVITY_COMPONENT, componentId, siteId);
if (!missingDependencies.length) {
return [];
}
// The activity had missing dependencies, but the package could have changed (e.g. the teacher fixed it).
// Check which of the dependencies apply to the current package.
const fileId = await CoreH5P.h5pFramework.getFileIdForMissingDependencies(fileUrl, siteId);
const filteredMissingDependencies = missingDependencies.filter(dependency =>
dependency.fileid === fileId && dependency.filetimemodified === deployedFile.timemodified);
if (filteredMissingDependencies.length > 0) {
return filteredMissingDependencies;
}
// Package has changed, delete previous missing dependencies.
await CorePromiseUtils.ignoreErrors(
CoreH5P.h5pFramework.deleteMissingDependenciesForComponent(ADDON_MOD_H5PACTIVITY_COMPONENT, componentId, siteId),
);
return [];
}
/**
* Get cache key for attemps WS calls.
*
@ -658,6 +700,20 @@ export class AddonModH5PActivityProvider {
}
/**
* Check if a package has missing dependencies.
*
* @param componentId Component ID.
* @param deployedFile File to check.
* @param siteId Site ID. If not defined, current site.
* @returns Whether the package has missing dependencies.
*/
async hasMissingDependencies(componentId: number, deployedFile: CoreWSFile, siteId?: string): Promise<boolean> {
const missingDependencies = await this.getMissingDependencies(componentId, deployedFile, siteId);
return missingDependencies.length > 0;
}
/**
* Invalidates access information.
*

View File

@ -144,6 +144,12 @@ export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivit
siteId: siteId,
});
// If we already detected that the file has missing dependencies there's no need to download it again.
const missingDependencies = await AddonModH5PActivity.getMissingDependencies(module.id, deployedFile, siteId);
if (missingDependencies.length > 0) {
throw CoreH5P.h5pFramework.buildMissingDependenciesErrorFromDBRecords(missingDependencies);
}
if (AddonModH5PActivity.isSaveStateEnabled(h5pActivity)) {
// If the file needs to be downloaded, delete the states because it means the package has changed or user deleted it.
const fileState = await CoreFilepool.getFileStateByUrl(siteId, CoreFileHelper.getFileUrl(deployedFile));
@ -254,6 +260,19 @@ export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivit
);
}
/**
* @inheritdoc
*/
async removeFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
// Remove files and delete any missing dependency stored to force recalculating them.
await Promise.all([
super.removeFiles(module, courseId),
CorePromiseUtils.ignoreErrors(
CoreH5P.h5pFramework.deleteMissingDependenciesForComponent(ADDON_MOD_H5PACTIVITY_COMPONENT, module.id),
),
]);
}
}
export const AddonModH5PActivityPrefetchHandler = makeSingleton(AddonModH5PActivityPrefetchHandlerService);

View File

@ -24,20 +24,29 @@ import { CoreErrorObject } from '@services/error-helper';
*/
export class CoreError extends Error {
title?: string;
debug?: CoreErrorDebug;
constructor(message?: string, debug?: CoreErrorDebug) {
constructor(message?: string, options: CoreErrorOptions = {}) {
super(message);
// Fix prototype chain: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
this.name = new.target.name;
Object.setPrototypeOf(this, new.target.prototype);
this.debug = debug;
this.title = options.title;
this.debug = options.debug;
}
}
export type CoreErrorOptions = {
// Error title. By default, 'Error'.
title?: string;
// Debugging information.
debug?: CoreErrorDebug;
};
/**
* Debug information of the error.
*/

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreError, CoreErrorDebug } from '@classes/errors/error';
import { CoreError, CoreErrorOptions } from '@classes/errors/error';
import { CoreUserSupportConfig } from '@features/user/classes/support/support-config';
/**
@ -20,13 +20,11 @@ import { CoreUserSupportConfig } from '@features/user/classes/support/support-co
*/
export class CoreSiteError extends CoreError {
debug?: CoreErrorDebug;
supportConfig?: CoreUserSupportConfig;
constructor(options: CoreSiteErrorOptions) {
super(options.message);
super(options.message, { title: options.title, debug: options.debug });
this.debug = options.debug;
this.supportConfig = options.supportConfig;
}
@ -43,12 +41,9 @@ export class CoreSiteError extends CoreError {
}
export type CoreSiteErrorOptions = {
export type CoreSiteErrorOptions = CoreErrorOptions & {
message: string;
// Debugging information.
debug?: CoreErrorDebug;
// Configuration to use to contact site support. If this attribute is present, it means
// that the error warrants contacting support.
supportConfig?: CoreUserSupportConfig;

View File

@ -1,10 +1,13 @@
{
"activitydisabled": "Your organisation has disabled this activity in the mobile app.",
"activitynotavailableoffline": "This activity is not available offline.",
"activitynotyetviewableremoteaddon": "Your organisation installed a plugin that is not yet supported.",
"activityrequiresconnection": "This activity is only available with an internet connection. If your device is offline, you will not be able to access it.",
"allsections": "All sections",
"aria:sectionprogress": "Section progress:",
"availablespace": "You currently have about {{available}} free space.",
"cannotdeletewhiledownloading": "Files cannot be deleted while the activity is being downloaded. Please wait for the download to finish.",
"changesofflinemaybelost": "Any changes you make to this activity while offline may not be saved.<br><br>Connect your device to the internet to avoid losing your progress.",
"communicationroomlink": "Chat to course participants",
"completion_automatic:done": "Done:",
"completion_automatic:failed": "Failed:",

View File

@ -0,0 +1,31 @@
// (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 '@classes/errors/error';
import { CoreH5PMissingLibrary } from '../core';
/**
* Missing dependencies error when deploying an H5P package.
*/
export class CoreH5PMissingDependenciesError extends CoreError {
missingDependencies: CoreH5PMissingLibrary[];
constructor(message: string, missingDependencies: CoreH5PMissingLibrary[]) {
super(message);
this.missingDependencies = missingDependencies;
}
}

View File

@ -24,6 +24,7 @@ import {
CoreH5PContentDepsTreeDependency,
CoreH5PLibraryBasicData,
CoreH5PLibraryBasicDataWithPatch,
CoreH5PMissingLibrary,
} from './core';
import {
CONTENT_TABLE_NAME,
@ -36,6 +37,10 @@ import {
CoreH5PLibraryDBRecord,
CoreH5PLibraryDependencyDBRecord,
CoreH5PContentsLibraryDBRecord,
CoreH5PMissingDependencyDBRecord,
MISSING_DEPENDENCIES_TABLE_NAME,
MISSING_DEPENDENCIES_PRIMARY_KEYS,
CoreH5PMissingDependencyDBPrimaryKeys,
} from '../services/database/h5p';
import { CoreError } from '@classes/errors/error';
import { CoreH5PSemantics } from './content-validator';
@ -48,6 +53,11 @@ import { LazyMap, lazyMap } from '@/core/utils/lazy-map';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy';
import { SubPartial } from '@/core/utils/types';
import { CoreH5PMissingDependenciesError } from './errors/missing-dependencies-error';
import { CoreFilepool } from '@services/filepool';
import { CoreFileHelper } from '@services/file-helper';
import { CoreUrl, CoreUrlPartNames } from '@singletons/url';
import { CorePromiseUtils } from '@singletons/promise-utils';
/**
* Equivalent to Moodle's implementation of H5PFrameworkInterface.
@ -59,6 +69,9 @@ export class CoreH5PFramework {
protected libraryDependenciesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreH5PLibraryDependencyDBRecord>>>;
protected contentsLibrariesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreH5PContentsLibraryDBRecord>>>;
protected librariesCachedAssetsTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreH5PLibraryCachedAssetsDBRecord>>>;
protected missingDependenciesTables: LazyMap<
AsyncInstance<CoreDatabaseTable<CoreH5PMissingDependencyDBRecord, CoreH5PMissingDependencyDBPrimaryKeys>>
>;
constructor() {
this.contentTables = lazyMap(
@ -121,6 +134,43 @@ export class CoreH5PFramework {
),
),
);
this.missingDependenciesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable(
MISSING_DEPENDENCIES_TABLE_NAME,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.missingDependenciesTables[siteId],
primaryKeyColumns: [...MISSING_DEPENDENCIES_PRIMARY_KEYS],
},
),
),
);
}
/**
* Given a list of missing dependencies DB records, create a missing dependencies error.
*
* @param missingDependencies List of missing dependencies.
* @returns Error instance.
*/
buildMissingDependenciesErrorFromDBRecords(
missingDependencies: CoreH5PMissingDependencyDBRecord[],
): CoreH5PMissingDependenciesError {
const missingLibraries = missingDependencies.map(dep => ({
machineName: dep.machinename,
majorVersion: dep.majorversion,
minorVersion: dep.minorversion,
libString: dep.requiredby,
}));
const errorMessage = Translate.instant('core.h5p.missingdependency', { $a: {
lib: missingLibraries[0].libString,
dep: CoreH5PCore.libraryToString(missingLibraries[0]),
} });
return new CoreH5PMissingDependenciesError(errorMessage, missingLibraries);
}
/**
@ -236,6 +286,33 @@ export class CoreH5PFramework {
await this.contentsLibrariesTables[siteId].delete({ h5pid: id });
}
/**
* Delete missing dependencies stored for a certain component and componentId.
*
* @param component Component.
* @param componentId Component ID.
* @param siteId Site ID.
*/
async deleteMissingDependenciesForComponent(component: string, componentId: string | number, siteId?: string): Promise<void> {
siteId ??= CoreSites.getCurrentSiteId();
await this.missingDependenciesTables[siteId].delete({ component, componentId });
}
/**
* Delete all the missing dependencies related to a certain library version.
*
* @param libraryData Library.
* @param siteId Site ID.
*/
protected async deleteMissingDependenciesForLibrary(libraryData: CoreH5PLibraryBasicData, siteId: string): Promise<void> {
await this.missingDependenciesTables[siteId].delete({
machinename: libraryData.machineName,
majorversion: libraryData.majorVersion,
minorversion: libraryData.minorVersion,
});
}
/**
* Get all conent data from DB.
*
@ -282,6 +359,38 @@ export class CoreH5PFramework {
}
}
/**
* Get an identifier for a file URL, used to store missing dependencies.
*
* @param fileUrl File URL.
* @param siteId Site ID. If not defined, current site.
* @returns An identifier for the file.
*/
async getFileIdForMissingDependencies(fileUrl: string, siteId?: string): Promise<string> {
siteId ??= CoreSites.getCurrentSiteId();
const isTrusted = await CoreH5P.isTrustedUrl(fileUrl, siteId);
if (!isTrusted) {
// Fix the URL, we need to URL of the trusted package.
const file = await CoreFilepool.fixPluginfileURL(siteId, fileUrl);
fileUrl = CoreFileHelper.getFileUrl(file);
}
// Remove all params from the URL except the time modified. We don't want the id to depend on changing params like
// the language or the token.
const urlParams = CoreUrl.extractUrlParams(fileUrl);
fileUrl = CoreUrl.addParamsToUrl(
CoreUrl.removeUrlParts(fileUrl, [CoreUrlPartNames.Query]),
{ modified: urlParams.modified },
);
// Only return the file args, that way the id doesn't depend on the endpoint to obtain the file.
const fileArgs = CoreUrl.getPluginFileArgs(fileUrl);
return fileArgs ? fileArgs.join('/') : fileUrl;
}
/**
* Get the latest library version.
*
@ -405,6 +514,47 @@ export class CoreH5PFramework {
return this.getLibraryId(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId);
}
/**
* Get missing dependencies stored for a certain component and componentId.
*
* @param component Component.
* @param componentId Component ID.
* @param siteId Site ID.
* @returns List of missing dependencies. Empty list if no missing dependencies stored for the file.
*/
async getMissingDependenciesForComponent(
component: string,
componentId: string | number,
siteId?: string,
): Promise<CoreH5PMissingDependencyDBRecord[]> {
siteId ??= CoreSites.getCurrentSiteId();
try {
return await this.missingDependenciesTables[siteId].getMany({ component, componentId });
} catch {
return [];
}
}
/**
* Get missing dependencies stored for a certain file.
*
* @param fileUrl File URL.
* @param siteId Site ID.
* @returns List of missing dependencies. Empty list if no missing dependencies stored for the file.
*/
async getMissingDependenciesForFile(fileUrl: string, siteId?: string): Promise<CoreH5PMissingDependencyDBRecord[]> {
siteId ??= CoreSites.getCurrentSiteId();
try {
const fileId = await this.getFileIdForMissingDependencies(fileUrl, siteId);
return await this.missingDependenciesTables[siteId].getMany({ fileid: fileId });
} catch {
return [];
}
}
/**
* Get the default behaviour for the display option defined.
*
@ -812,6 +962,9 @@ export class CoreH5PFramework {
// Updated libary. Remove old dependencies.
await this.deleteLibraryDependencies(data.id, siteId);
}
// Delete missing dependencies related to this library. Don't block the execution for this.
CorePromiseUtils.ignoreErrors(this.deleteMissingDependenciesForLibrary(libraryData, siteId));
}
/**
@ -830,6 +983,7 @@ export class CoreH5PFramework {
siteId?: string,
): Promise<void> {
const targetSiteId = siteId ?? CoreSites.getCurrentSiteId();
const libString = CoreH5PCore.libraryToString(library);
await Promise.all(dependencies.map(async (dependency) => {
// Get the ID of the library.
@ -837,10 +991,10 @@ export class CoreH5PFramework {
if (!dependencyId) {
// Missing dependency. It should have been detected before installing the package.
throw new CoreError(Translate.instant('core.h5p.missingdependency', { $a: {
throw new CoreH5PMissingDependenciesError(Translate.instant('core.h5p.missingdependency', { $a: {
lib: CoreH5PCore.libraryToString(library),
dep: CoreH5PCore.libraryToString(dependency),
} }));
} }), [{ ...dependency, libString }]);
}
// Create the relation.
@ -900,6 +1054,34 @@ export class CoreH5PFramework {
}));
}
/**
* Store missing dependencies in DB.
*
* @param fileUrl URL of the package that has missing dependencies.
* @param missingDependencies List of missing dependencies.
* @param options Other options.
*/
async storeMissingDependencies(
fileUrl: string,
missingDependencies: CoreH5PMissingLibrary[],
options: StoreMissingDependenciesOptions = {},
): Promise<void> {
const targetSiteId = options.siteId ?? CoreSites.getCurrentSiteId();
const fileId = await this.getFileIdForMissingDependencies(fileUrl, targetSiteId);
await Promise.all(missingDependencies.map((missingLibrary) => this.missingDependenciesTables[targetSiteId].insert({
fileid: fileId,
machinename: missingLibrary.machineName,
majorversion: missingLibrary.majorVersion,
minorversion: missingLibrary.minorVersion,
requiredby: missingLibrary.libString,
filetimemodified: options.fileTimemodified ?? 0,
component: options.component,
componentId: options.componentId,
})));
}
/**
* Save content data in DB and clear cache.
*
@ -1011,3 +1193,13 @@ type LibraryDependency = {
type LibraryAddonDBData = Omit<CoreH5PLibraryAddonData, 'addTo'> & {
addTo: string;
};
/**
* Options for storeMissingDependencies.
*/
type StoreMissingDependenciesOptions = {
component?: string;
componentId?: string | number;
fileTimemodified?: number;
siteId?: string;
};

View File

@ -21,6 +21,9 @@ import { CoreH5P } from '../services/h5p';
import { CoreH5PCore, CoreH5PDisplayOptions, CoreH5PLocalization } from './core';
import { CoreError } from '@classes/errors/error';
import { CorePath } from '@singletons/path';
import { CorePluginFileTreatDownloadedFileOptions } from '@services/plugin-file-delegate';
import { CoreH5PMissingDependenciesError } from './errors/missing-dependencies-error';
import { CorePromiseUtils } from '@singletons/promise-utils';
/**
* Equivalent to Moodle's H5P helper class.
@ -89,13 +92,13 @@ export class CoreH5PHelper {
// Add core stylesheets.
CoreH5PCore.STYLES.forEach((style) => {
settings.core!.styles.push(libUrl + style);
settings.core?.styles.push(libUrl + style);
cssRequires.push(libUrl + style);
});
// Add core JavaScript.
CoreH5PCore.getScripts().forEach((script) => {
settings.core!.scripts.push(script);
settings.core?.scripts.push(script);
jsRequires.push(script);
});
@ -163,19 +166,22 @@ export class CoreH5PHelper {
*
* @param fileUrl The file URL used to download the file.
* @param file The file entry of the downloaded file.
* @param siteId Site ID. If not defined, current site.
* @param onProgress Function to call on progress.
* @param options Options.
* @returns Promise resolved when done.
*/
static async saveH5P(fileUrl: string, file: FileEntry, siteId?: string, onProgress?: CoreH5PSaveOnProgress): Promise<void> {
siteId = siteId || CoreSites.getCurrentSiteId();
static async saveH5P(
fileUrl: string,
file: FileEntry,
options: CorePluginFileTreatDownloadedFileOptions<ProgressEvent | { message: string }> = {},
): Promise<void> {
const siteId = options.siteId || CoreSites.getCurrentSiteId();
// Notify that the unzip is starting.
onProgress && onProgress({ message: 'core.unzipping' });
options.onProgress && options.onProgress({ message: 'core.unzipping' });
const queueId = siteId + ':saveH5P:' + fileUrl;
await CoreH5P.queueRunner.run(queueId, () => CoreH5PHelper.performSave(fileUrl, file, siteId, onProgress));
await CoreH5P.queueRunner.run(queueId, () => CoreH5PHelper.performSave(fileUrl, file, { ...options, siteId }));
}
/**
@ -183,40 +189,54 @@ export class CoreH5PHelper {
*
* @param fileUrl The file URL used to download the file.
* @param file The file entry of the downloaded file.
* @param siteId Site ID. If not defined, current site.
* @param onProgress Function to call on progress.
* @param options Options.
* @returns Promise resolved when done.
*/
protected static async performSave(
fileUrl: string,
file: FileEntry,
siteId?: string,
onProgress?: CoreH5PSaveOnProgress,
options: CorePluginFileTreatDownloadedFileOptions<ProgressEvent | { message: string }> = {},
): Promise<void> {
const folderName = CoreMimetypeUtils.removeExtension(file.name);
const destFolder = CorePath.concatenatePaths(CoreFileProvider.TMPFOLDER, 'h5p/' + folderName);
// Unzip the file.
await CoreFile.unzipFile(CoreFile.getFileEntryURL(file), destFolder, onProgress);
await CoreFile.unzipFile(CoreFile.getFileEntryURL(file), destFolder, options.onProgress);
try {
// Notify that the unzip is starting.
onProgress && onProgress({ message: 'core.storingfiles' });
options.onProgress && options.onProgress({ message: 'core.storingfiles' });
// Read the contents of the unzipped dir, process them and store them.
const contents = await CoreFile.getDirectoryContents(destFolder);
const filesData = await CoreH5P.h5pValidator.processH5PFiles(destFolder, contents, siteId);
const filesData = await CoreH5P.h5pValidator.processH5PFiles(destFolder, contents, options.siteId);
const content = await CoreH5P.h5pStorage.savePackage(filesData, folderName, fileUrl, false, siteId);
const content = await CoreH5P.h5pStorage.savePackage(filesData, folderName, fileUrl, false, options.siteId);
// Create the content player.
const contentData = await CoreH5P.h5pCore.loadContent(content.id, undefined, siteId);
const contentData = await CoreH5P.h5pCore.loadContent(content.id, undefined, options.siteId);
const embedType = CoreH5PCore.determineEmbedType(contentData.embedType, contentData.library.embedTypes);
await CoreH5P.h5pPlayer.createContentIndex(content.id!, fileUrl, contentData, embedType, siteId);
await CoreH5P.h5pPlayer.createContentIndex(content.id!, fileUrl, contentData, embedType, options.siteId);
} catch (error) {
if (error instanceof CoreH5PMissingDependenciesError) {
// Store the missing dependencies to avoid re-downloading the file every time.
await CorePromiseUtils.ignoreErrors(CoreH5P.h5pFramework.storeMissingDependencies(
fileUrl,
error.missingDependencies,
{
component: options.component,
componentId: options.componentId,
fileTimemodified: options.timemodified,
siteId: options.siteId,
},
));
}
throw error;
} finally {
// Remove tmp folder.
try {
@ -264,5 +284,3 @@ export type CoreH5PCoreSettings = {
loadedJs?: string[];
loadedCss?: string[];
};
export type CoreH5PSaveOnProgress = (event?: ProgressEvent | { message: string }) => void;

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreError } from '@classes/errors/error';
import { CoreH5PMissingDependenciesError } from './errors/missing-dependencies-error';
import { FileEntry, DirectoryEntry } from '@awesome-cordova-plugins/file/ngx';
import { CoreFile, CoreFileFormat } from '@services/file';
import { Translate } from '@singletons';
@ -269,10 +269,10 @@ export class CoreH5PValidator {
const libString = Object.keys(missingLibraries)[0];
const missingLibrary = missingLibraries[libString];
throw new CoreError(Translate.instant('core.h5p.missingdependency', { $a: {
throw new CoreH5PMissingDependenciesError(Translate.instant('core.h5p.missingdependency', { $a: {
lib: missingLibrary.libString,
dep: libString,
} }));
} }), Object.values(missingLibraries));
}
return { librariesJsonData, mainJsonData, contentJsonData };

View File

@ -49,6 +49,9 @@ export class CoreH5PIframeComponent implements OnChanges, OnDestroy {
@Input({ transform: toBoolean }) enableInAppFullscreen = false; // Whether to enable our custom in-app fullscreen feature.
@Input() saveFreq?: number; // Save frequency (in seconds) if enabled.
@Input() state?: string; // Initial content state.
@Input() component?: string; // Component the file is linked to.
@Input() componentId?: string | number; // Component ID.
@Input() fileTimemodified?: number; // The timemodified of the file.
@Output() onIframeUrlSet = new EventEmitter<{src: string; online: boolean}>();
@Output() onIframeLoaded = new EventEmitter<void>();
@ -181,7 +184,12 @@ export class CoreH5PIframeComponent implements OnChanges, OnDestroy {
const file = await CoreFile.getFile(path);
await CoreH5PHelper.saveH5P(this.fileUrl!, file, this.siteId);
await CoreH5PHelper.saveH5P(this.fileUrl!, file, {
siteId: this.siteId,
component: this.component,
componentId: this.componentId,
timemodified: this.fileTimemodified,
});
// File treated. Try to get the index file URL again.
const url = await CoreH5P.h5pPlayer.getContentIndexFileUrl(

View File

@ -9,4 +9,5 @@
</div>
</div>
<core-h5p-iframe *ngIf="showPackage" [fileUrl]="urlParams!.url" [displayOptions]="displayOptions" [onlinePlayerUrl]="src" />
<core-h5p-iframe *ngIf="showPackage" [fileUrl]="urlParams!.url" [displayOptions]="displayOptions" [onlinePlayerUrl]="src"
[component]="component" [componentId]="componentId" [fileTimemodified]="fileTimemodified" />

View File

@ -1,3 +1,5 @@
@use "theme/globals" as *;
:host {
--core-h5p-placeholder-bg-color: var(--gray-300);
--core-h5p-placeholder-text-color: var(--ion-text-color);

View File

@ -41,6 +41,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy {
@Input() src?: string; // The URL of the player to display the H5P package.
@Input() component?: string; // Component.
@Input() componentId?: string | number; // Component ID to use in conjunction with the component.
@Input() fileTimemodified?: number; // The timemodified of the package file.
showPackage = false;
state?: string;
@ -122,6 +123,12 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
try {
// Check if the package has missing dependencies. If so, it cannot be downloaded.
const missingDependencies = await CoreH5P.h5pFramework.getMissingDependenciesForFile(this.urlParams.url);
if (missingDependencies.length > 0) {
throw CoreH5P.h5pFramework.buildMissingDependenciesErrorFromDBRecords(missingDependencies);
}
// Get the file size and ask the user to confirm.
const size = await CorePluginFileDelegate.getFileSize({ fileurl: this.urlParams.url }, this.siteId);
@ -152,6 +159,12 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy {
return;
}
// Check if the package has missing dependencies. If so, it cannot be downloaded.
const missingDependencies = await CoreH5P.h5pFramework.getMissingDependenciesForFile(this.urlParams.url);
if (missingDependencies.length > 0) {
return;
}
// Get the file size.
const size = await CorePluginFileDelegate.getFileSize({ fileurl: this.urlParams.url }, this.siteId);

View File

@ -24,15 +24,19 @@ export const LIBRARIES_TABLE_NAME = 'h5p_libraries'; // Installed libraries.
export const LIBRARY_DEPENDENCIES_TABLE_NAME = 'h5p_library_dependencies'; // Library dependencies.
export const CONTENTS_LIBRARIES_TABLE_NAME = 'h5p_contents_libraries'; // Which library is used in which content.
export const LIBRARIES_CACHEDASSETS_TABLE_NAME = 'h5p_libraries_cachedassets'; // H5P cached library assets.
export const MISSING_DEPENDENCIES_TABLE_NAME = 'h5p_missing_dependencies'; // Information about missing dependencies.
export const MISSING_DEPENDENCIES_PRIMARY_KEYS = ['fileid', 'machinename', 'majorversion', 'minorversion'] as const;
export const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreH5PProvider',
version: 2,
version: 3,
canBeCleared: [
CONTENT_TABLE_NAME,
LIBRARIES_TABLE_NAME,
LIBRARY_DEPENDENCIES_TABLE_NAME,
CONTENTS_LIBRARIES_TABLE_NAME,
LIBRARIES_CACHEDASSETS_TABLE_NAME,
MISSING_DEPENDENCIES_TABLE_NAME,
],
tables: [
{
@ -243,6 +247,46 @@ export const SITE_SCHEMA: CoreSiteSchema = {
},
],
},
{
name: MISSING_DEPENDENCIES_TABLE_NAME,
columns: [
{
name: 'fileid',
type: 'TEXT',
},
{
name: 'machinename',
type: 'TEXT',
},
{
name: 'majorversion',
type: 'INTEGER',
},
{
name: 'minorversion',
type: 'INTEGER',
},
{
name: 'requiredby',
type: 'TEXT',
notNull: true,
},
{
name: 'filetimemodified',
type: 'INTEGER',
notNull: true,
},
{
name: 'component',
type: 'TEXT',
},
{
name: 'componentId',
type: 'TEXT',
},
],
primaryKeys: [...MISSING_DEPENDENCIES_PRIMARY_KEYS],
},
],
async migrate(db: SQLiteDB, oldVersion: number): Promise<void> {
if (oldVersion >= 2) {
@ -320,3 +364,19 @@ export type CoreH5PLibraryCachedAssetsDBRecord = {
hash: string; // The hash to identify the cached asset.
foldername: string; // Name of the folder that contains the contents.
};
/**
* Structure of missing dependency data stored in DB.
*/
export type CoreH5PMissingDependencyDBRecord = {
fileid: string; // Identifier of the package that has a missing dependency. It will be part of the file url.
filetimemodified: number; // Time when the file was last modified.
machinename: string; // Machine name of the missing dependency.
majorversion: number; // Major version of the missing dependency.
minorversion: number; // Minor version of the missing dependency.
requiredby: string; // LibString of the library that requires the missing dependency.
component?: string; // Component related to the package.
componentId?: string | number; // Component ID related to the package.
};
export type CoreH5PMissingDependencyDBPrimaryKeys = typeof MISSING_DEPENDENCIES_PRIMARY_KEYS[number];

View File

@ -234,6 +234,19 @@ export class CoreH5PProvider {
return !!(site?.isOfflineDisabled() || site?.isFeatureDisabled('NoDelegate_H5POffline'));
}
/**
* Given an H5P URL, check if it's a trusted URL.
*
* @param fileUrl File URL to check.
* @param siteId Site ID. If not defined, current site.
* @returns Whether it's a trusted URL.
*/
async isTrustedUrl(fileUrl: string, siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
return site.containsUrl(fileUrl) && !!fileUrl.match(/pluginfile\.php\/([^/]+\/)?[^/]+\/core_h5p\/export\//i);
}
/**
* Treat an H5P url before sending it to WS.
*

View File

@ -15,8 +15,11 @@
import { Injectable } from '@angular/core';
import { FileEntry } from '@awesome-cordova-plugins/file/ngx';
import { CoreFilepoolOnProgressCallback } from '@services/filepool';
import { CorePluginFileDownloadableResult, CorePluginFileHandler } from '@services/plugin-file-delegate';
import {
CorePluginFileDownloadableResult,
CorePluginFileHandler,
CorePluginFileTreatDownloadedFileOptions,
} from '@services/plugin-file-delegate';
import { CoreSites } from '@services/sites';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreUrl } from '@singletons/url';
@ -36,12 +39,7 @@ export class CoreH5PPluginFileHandlerService implements CorePluginFileHandler {
name = 'CoreH5PPluginFileHandler';
/**
* React to a file being deleted.
*
* @param fileUrl The file URL used to download the file.
* @param path The path of the deleted file.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done.
* @inheritdoc
*/
async fileDeleted(fileUrl: string, path: string, siteId?: string): Promise<void> {
// If an h5p file is deleted, remove the contents folder.
@ -49,18 +47,14 @@ export class CoreH5PPluginFileHandlerService implements CorePluginFileHandler {
}
/**
* Check whether a file can be downloaded. If so, return the file to download.
*
* @param file The file data.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with the file to use. Rejected if cannot download.
* @inheritdoc
*/
async getDownloadableFile(file: CoreWSFile, siteId?: string): Promise<CoreWSFile> {
const site = await CoreSites.getSite(siteId);
siteId = siteId || CoreSites.getCurrentSiteId();
const fileUrl = CoreFileHelper.getFileUrl(file);
if (site.containsUrl(fileUrl) && fileUrl.match(/pluginfile\.php\/[^/]+\/core_h5p\/export\//i)) {
const isTrusted = await CoreH5P.isTrustedUrl(fileUrl, siteId);
if (isTrusted) {
// It's already a deployed file, use it.
return file;
}
@ -69,11 +63,7 @@ export class CoreH5PPluginFileHandlerService implements CorePluginFileHandler {
}
/**
* Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by
* CoreFilepoolProvider.extractDownloadableFilesFromHtml.
*
* @param container Container where to get the URLs from.
* @returns List of URLs.
* @inheritdoc
*/
getDownloadableFilesFromHTML(container: HTMLElement): string[] {
const iframes = <HTMLIFrameElement[]> Array.from(container.querySelectorAll('iframe.h5p-iframe'));
@ -91,11 +81,7 @@ export class CoreH5PPluginFileHandlerService implements CorePluginFileHandler {
}
/**
* Get a file size.
*
* @param file The file data.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with the size.
* @inheritdoc
*/
async getFileSize(file: CoreWSFile, siteId?: string): Promise<number> {
try {
@ -113,20 +99,14 @@ export class CoreH5PPluginFileHandlerService implements CorePluginFileHandler {
}
/**
* Whether or not the handler is enabled on a site level.
*
* @returns Whether or not the handler is enabled on a site level.
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return CoreH5P.canGetTrustedH5PFileInSite();
}
/**
* Check if a file is downloadable.
*
* @param file The file data.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with a boolean and a reason why it isn't downloadable if needed.
* @inheritdoc
*/
async isFileDownloadable(file: CoreWSFile, siteId?: string): Promise<CorePluginFileDownloadableResult> {
const offlineDisabled = await CoreH5P.isOfflineDisabled(siteId);
@ -144,31 +124,21 @@ export class CoreH5PPluginFileHandlerService implements CorePluginFileHandler {
}
/**
* Check whether the file should be treated by this handler. It is used in functions where the component isn't used.
*
* @param file The file data.
* @returns Whether the file should be treated by this handler.
* @inheritdoc
*/
shouldHandleFile(file: CoreWSFile): boolean {
return CoreMimetypeUtils.guessExtensionFromUrl(CoreFileHelper.getFileUrl(file)) == 'h5p';
}
/**
* Treat a downloaded file.
*
* @param fileUrl The file URL used to download the file.
* @param file The file entry of the downloaded file.
* @param siteId Site ID. If not defined, current site.
* @param onProgress Function to call on progress.
* @returns Promise resolved when done.
* @inheritdoc
*/
treatDownloadedFile(
fileUrl: string,
file: FileEntry,
siteId?: string,
onProgress?: CoreFilepoolOnProgressCallback,
options: CorePluginFileTreatDownloadedFileOptions = {},
): Promise<void> {
return CoreH5PHelper.saveH5P(fileUrl, file, siteId, onProgress);
return CoreH5PHelper.saveH5P(fileUrl, file, options);
}
}

View File

@ -55,6 +55,7 @@
"confirmopeninbrowser": "Do you want to open it in a web browser?",
"confirmremoveselectedfile": "This will permanently delete '{{filename}}'. You can't undo this.",
"confirmremoveselectedfiles": "This will permanently delete selected files. You can't undo this.",
"connectandtryagain": "Please connect to the internet and try again.",
"connectionlost": "Connection to site lost",
"considereddigitalminor": "You are too young to create an account on this site.",
"contactsupport": "Contact support",
@ -212,6 +213,7 @@
"mygroups": "My groups",
"name": "Name",
"needhelp": "Need help?",
"needinternettoaccessit": "You need to be connected to the internet to access it.",
"networkerroriframemsg": "This content is not available offline. Please connect to the internet and try again.",
"networkerrormsg": "There was a problem connecting to the site. Please check your connection and try again.",
"never": "Never",

View File

@ -748,19 +748,13 @@ export class CoreFilepoolProvider {
*
* @param siteId The site ID.
* @param fileUrl The file URL.
* @param options Extra options (revision, timemodified, isexternalfile, repositorytype).
* @param filePath Filepath to download the file to. If defined, no extension will be added.
* @param onProgress Function to call on progress.
* @param poolFileObject When set, the object will be updated, a new entry will not be created.
* @param options Extra options.
* @returns Resolved with internal URL on success, rejected otherwise.
*/
protected async downloadForPoolByUrl(
siteId: string,
fileUrl: string,
options: CoreFilepoolFileOptions = {},
filePath?: string,
onProgress?: CoreFilepoolOnProgressCallback,
poolFileObject?: CoreFilepoolFileEntry,
options: DownloadForPoolOptions = {},
): Promise<string> {
const fileId = this.getFileIdByUrl(fileUrl);
@ -771,10 +765,10 @@ export class CoreFilepoolProvider {
}
const extension = CoreMimetypeUtils.guessExtensionFromUrl(fileUrl);
const addExtension = filePath === undefined;
const path = filePath || (await this.getFilePath(siteId, fileId, extension, fileUrl));
const addExtension = options.filePath === undefined;
const path = options.filePath || (await this.getFilePath(siteId, fileId, extension, fileUrl));
if (poolFileObject && poolFileObject.fileId !== fileId) {
if (options.poolFileObject && options.poolFileObject.fileId !== fileId) {
this.logger.error('Invalid object to update passed');
throw new CoreError('Invalid object to update passed.');
@ -794,9 +788,15 @@ export class CoreFilepoolProvider {
throw new CoreError(Translate.instant('core.cannotdownloadfiles'));
}
const entry = await CoreWS.downloadFile(fileUrl, path, addExtension, onProgress);
const entry = await CoreWS.downloadFile(fileUrl, path, addExtension, options.onProgress);
const fileEntry = entry;
await CorePluginFileDelegate.treatDownloadedFile(fileUrl, fileEntry, siteId, onProgress);
await CorePluginFileDelegate.treatDownloadedFile(fileUrl, fileEntry, {
siteId,
onProgress: options.onProgress,
component: options.component,
componentId: options.componentId,
timemodified: options.timemodified,
});
await this.addFileToPool(siteId, fileId, {
downloadTime: Date.now(),
@ -1126,7 +1126,14 @@ export class CoreFilepoolProvider {
alreadyDownloaded = false;
try {
const url = await this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress);
const url = await this.downloadForPoolByUrl(siteId, fileUrl, {
...options,
filePath,
onProgress,
component,
componentId,
timemodified: options.timemodified,
});
return finishSuccessfulDownload(url);
} catch (error) {
@ -1220,7 +1227,7 @@ export class CoreFilepoolProvider {
* @param timemodified The timemodified of the file.
* @returns Promise resolved with the file data to use.
*/
protected async fixPluginfileURL(siteId: string, fileUrl: string, timemodified: number = 0): Promise<CoreWSFile> {
async fixPluginfileURL(siteId: string, fileUrl: string, timemodified: number = 0): Promise<CoreWSFile> {
const file = await CorePluginFileDelegate.getDownloadableFile({ fileurl: fileUrl, timemodified });
const site = await CoreSites.getSite(siteId);
@ -2713,7 +2720,15 @@ export class CoreFilepoolProvider {
const onProgress = this.getQueueOnProgress(siteId, fileId);
try {
await this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, entry);
await this.downloadForPoolByUrl(siteId, fileUrl, {
...options,
filePath,
onProgress,
poolFileObject: entry,
component: item.linksUnserialized?.[0]?.component,
componentId: item.linksUnserialized?.[0]?.componentId,
timemodified: options.timemodified,
});
// Success, we add links and remove from queue.
CorePromiseUtils.ignoreErrors(this.addFileLinks(siteId, fileId, links));
@ -2752,12 +2767,12 @@ export class CoreFilepoolProvider {
dropFromQueue = true;
}
let errorMessage: string | undefined;
let error = errorObject;
// Some Android devices restrict the amount of usable storage using quotas.
// If this quota would be exceeded by the download, it throws an exception.
// We catch this exception here, and report a meaningful error message to the user.
if (errorObject instanceof FileTransferError && errorObject.exception && errorObject.exception.includes('EDQUOT')) {
errorMessage = 'core.course.insufficientavailablequota';
error = new Error(Translate.instant('core.course.insufficientavailablequota'));
}
if (dropFromQueue) {
@ -2765,11 +2780,11 @@ export class CoreFilepoolProvider {
await CorePromiseUtils.ignoreErrors(this.removeFromQueue(siteId, fileId));
this.treatQueueDeferred(siteId, fileId, false, errorMessage);
this.treatQueueDeferred(siteId, fileId, false, error);
this.notifyFileDownloadError(siteId, fileId, links);
} else {
// We considered the file as legit but did not get it, failure.
this.treatQueueDeferred(siteId, fileId, false, errorMessage);
this.treatQueueDeferred(siteId, fileId, false, error);
this.notifyFileDownloadError(siteId, fileId, links);
throw errorObject;
@ -3124,12 +3139,12 @@ export class CoreFilepoolProvider {
* @param resolve True if promise should be resolved, false if it should be rejected.
* @param error String identifier for error message, if rejected.
*/
protected treatQueueDeferred(siteId: string, fileId: string, resolve: boolean, error?: string): void {
protected treatQueueDeferred(siteId: string, fileId: string, resolve: boolean, error?: Error | string): void {
if (siteId in this.queueDeferreds && fileId in this.queueDeferreds[siteId]) {
if (resolve) {
this.queueDeferreds[siteId][fileId].resolve();
} else {
this.queueDeferreds[siteId][fileId].reject(new Error(error));
this.queueDeferreds[siteId][fileId].reject(typeof error === 'string' ? new Error(error) : error);
}
delete this.queueDeferreds[siteId][fileId];
}
@ -3241,3 +3256,15 @@ type CoreFilepoolPromisedValue = CorePromisedValue<void> & {
type AnchorOrMediaElement =
HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | HTMLTrackElement;
/**
* Options for downloadForPoolByUrl.
*/
type DownloadForPoolOptions = CoreFilepoolFileOptions & {
filePath?: string; // Filepath to download the file to. If defined, no extension will be added.
onProgress?: CoreFilepoolOnProgressCallback; // Function to call on progress.
poolFileObject?: CoreFilepoolFileEntry; // When set, the object will be updated, a new entry will not be created.
component?: string; // The component to link the file to.
componentId?: string | number; // An ID to use in conjunction with the component.
timemodified?: number; // The time the file was modified.
};

View File

@ -271,20 +271,18 @@ export class CorePluginFileDelegateService extends CoreDelegate<CorePluginFileHa
*
* @param fileUrl The file URL used to download the file.
* @param file The file entry of the downloaded file.
* @param siteId Site ID. If not defined, current site.
* @param onProgress Function to call on progress.
* @param options Options.
* @returns Promise resolved when done.
*/
async treatDownloadedFile(
fileUrl: string,
file: FileEntry,
siteId?: string,
onProgress?: CoreFilepoolOnProgressCallback,
options: CorePluginFileTreatDownloadedFileOptions = {},
): Promise<void> {
const handler = this.getHandlerForFile({ fileurl: fileUrl });
if (handler && handler.treatDownloadedFile) {
await handler.treatDownloadedFile(fileUrl, file, siteId, onProgress);
await handler.treatDownloadedFile(fileUrl, file, options);
}
}
@ -378,16 +376,14 @@ export interface CorePluginFileHandler extends CoreDelegateHandler {
*
* @param fileUrl The file URL used to download the file.
* @param file The file entry of the downloaded file.
* @param siteId Site ID. If not defined, current site.
* @param onProgress Function to call on progress.
* @param options Options.
* @returns Promise resolved when done.
*/
treatDownloadedFile?(
fileUrl: string,
file: FileEntry,
siteId?: string,
onProgress?: CoreFilepoolOnProgressCallback):
Promise<void>;
options?: CorePluginFileTreatDownloadedFileOptions,
): Promise<void>;
}
/**
@ -412,3 +408,14 @@ export type CoreFileSizeSum = {
size: number; // Sum of file sizes.
total: boolean; // False if any file size is not available.
};
/**
* Options for treatDownloadedFile.
*/
export type CorePluginFileTreatDownloadedFileOptions<T = unknown> = {
siteId?: string; // Site ID. If not defined, current site.
onProgress?: CoreFilepoolOnProgressCallback<T>; // Function to call on progress.
component?: string; // The component to link the file to.
componentId?: string | number; // An ID to use in conjunction with the component.
timemodified?: number; // The timemodified of the file.
};

View File

@ -286,10 +286,10 @@ export class CoreSitesProvider {
siteUrl = CoreUrl.formatURL(siteUrl);
if (!CoreUrl.isHttpURL(siteUrl)) {
throw new CoreError(Translate.instant('core.login.invalidsite'), {
throw new CoreError(Translate.instant('core.login.invalidsite'), { debug: {
code: 'invalidprotocol',
details: `URL contains an invalid protocol when checking site.<br><br>Origin: ${origin}.<br><br>URL: ${siteUrl}.`,
});
} });
}
if (!CoreNetwork.isOnline()) {

View File

@ -52,10 +52,10 @@ export class CoreCustomURLSchemesProvider {
* @returns Error.
*/
protected createInvalidSchemeError(url: string, data?: CoreCustomURLSchemesParams): CoreCustomURLSchemesHandleError {
const defaultError = new CoreError(Translate.instant('core.login.invalidsite'), {
const defaultError = new CoreError(Translate.instant('core.login.invalidsite'), { debug: {
code: 'invalidurlscheme',
details: `Error when treating a URL scheme, it seems the URL is not valid.<br><br>URL: ${url}`,
});
} });
return new CoreCustomURLSchemesHandleError(defaultError, data);
}
@ -399,11 +399,11 @@ export class CoreCustomURLSchemesProvider {
// Error decoding the parameter.
this.logger.error('Error decoding parameter received for login SSO');
throw new CoreCustomURLSchemesHandleError(new CoreError(Translate.instant('core.login.invalidsite'), {
throw new CoreCustomURLSchemesHandleError(new CoreError(Translate.instant('core.login.invalidsite'), { debug: {
code: 'errordecodingparameter',
details: `Error when trying to decode base 64 string.<br><br>URL: ${originalUrl}<br><br>Text to decode: ${url}` +
`<br><br>Error: ${CoreErrorHelper.getErrorMessageFromError(err)}`,
}));
} }));
}
const data: CoreCustomURLSchemesParams = await CoreLoginHelper.validateBrowserSSOLogin(url);
@ -529,10 +529,10 @@ export class CoreCustomURLSchemesProvider {
CoreLoginHelper.treatUserTokenError(error.data.siteUrl, <CoreWSError> error.error);
CoreSites.logout();
} else {
CoreDomUtils.showErrorModal(error.error ?? new CoreError(Translate.instant('core.login.invalidsite'), {
CoreDomUtils.showErrorModal(error.error ?? new CoreError(Translate.instant('core.login.invalidsite'), { debug: {
code: 'unknownerror',
details: 'Unknown error when treating a URL scheme.',
}));
} }));
}
}

View File

@ -5,6 +5,7 @@ For more information about upgrading, read the official documentation: https://m
=== 5.0.0 ===
- The logout process has been refactored, now it uses a logout page to trigger Angular guards. CoreSites.logout now uses this process, and CoreSites.logoutForRedirect is deprecated and shouldn't be used anymore.
- The parameters of treatDownloadedFile of plugin file handlers have changed. Now the third parameter is an object with all the optional parameters.
=== 4.5.0 ===