diff --git a/scripts/langindex.json b/scripts/langindex.json index dd460ea09..480df6cfd 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -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", diff --git a/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html index fbff14346..3918ef7a1 100644 --- a/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html +++ b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html @@ -27,15 +27,21 @@ - - + + @if (triedToPlay && !isOnline && (!siteCanDownload || hasMissingDependencies)) { + + } @@ -69,7 +75,7 @@ + [state]="contentState" [component]="component" [componentId]="componentId" [fileTimemodified]="deployedFile?.timemodified" /> diff --git a/src/addons/mod/h5pactivity/components/index/index.ts b/src/addons/mod/h5pactivity/components/index/index.ts index 9fcc02cd2..3961402ec 100644 --- a/src/addons/mod/h5pactivity/components/index/index.ts +++ b/src/addons/mod/h5pactivity/components/index/index.ts @@ -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; 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(() => { diff --git a/src/addons/mod/h5pactivity/services/h5pactivity.ts b/src/addons/mod/h5pactivity/services/h5pactivity.ts index 7065efe8e..d34354fd2 100644 --- a/src/addons/mod/h5pactivity/services/h5pactivity.ts +++ b/src/addons/mod/h5pactivity/services/h5pactivity.ts @@ -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 { + 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 { + const missingDependencies = await this.getMissingDependencies(componentId, deployedFile, siteId); + + return missingDependencies.length > 0; + } + /** * Invalidates access information. * diff --git a/src/addons/mod/h5pactivity/services/handlers/prefetch.ts b/src/addons/mod/h5pactivity/services/handlers/prefetch.ts index 2985e7d0f..467014058 100644 --- a/src/addons/mod/h5pactivity/services/handlers/prefetch.ts +++ b/src/addons/mod/h5pactivity/services/handlers/prefetch.ts @@ -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 { + // 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); diff --git a/src/core/classes/errors/error.ts b/src/core/classes/errors/error.ts index 44ed1a7d2..48bfb0d84 100644 --- a/src/core/classes/errors/error.ts +++ b/src/core/classes/errors/error.ts @@ -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. */ diff --git a/src/core/classes/errors/siteerror.ts b/src/core/classes/errors/siteerror.ts index 62dffed76..6bb94712b 100644 --- a/src/core/classes/errors/siteerror.ts +++ b/src/core/classes/errors/siteerror.ts @@ -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; diff --git a/src/core/features/course/lang.json b/src/core/features/course/lang.json index 0717512b9..b7a8a33cb 100644 --- a/src/core/features/course/lang.json +++ b/src/core/features/course/lang.json @@ -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.

Connect your device to the internet to avoid losing your progress.", "communicationroomlink": "Chat to course participants", "completion_automatic:done": "Done:", "completion_automatic:failed": "Failed:", diff --git a/src/core/features/h5p/classes/errors/missing-dependencies-error.ts b/src/core/features/h5p/classes/errors/missing-dependencies-error.ts new file mode 100644 index 000000000..6b96b7552 --- /dev/null +++ b/src/core/features/h5p/classes/errors/missing-dependencies-error.ts @@ -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; + } + +} diff --git a/src/core/features/h5p/classes/framework.ts b/src/core/features/h5p/classes/framework.ts index e2b3c6afb..1747ceee9 100644 --- a/src/core/features/h5p/classes/framework.ts +++ b/src/core/features/h5p/classes/framework.ts @@ -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>>; protected contentsLibrariesTables: LazyMap>>; protected librariesCachedAssetsTables: LazyMap>>; + protected missingDependenciesTables: LazyMap< + AsyncInstance> + >; 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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 & { addTo: string; }; + +/** + * Options for storeMissingDependencies. + */ +type StoreMissingDependenciesOptions = { + component?: string; + componentId?: string | number; + fileTimemodified?: number; + siteId?: string; +}; diff --git a/src/core/features/h5p/classes/helper.ts b/src/core/features/h5p/classes/helper.ts index 375f7add3..6b44844eb 100644 --- a/src/core/features/h5p/classes/helper.ts +++ b/src/core/features/h5p/classes/helper.ts @@ -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 { - siteId = siteId || CoreSites.getCurrentSiteId(); + static async saveH5P( + fileUrl: string, + file: FileEntry, + options: CorePluginFileTreatDownloadedFileOptions = {}, + ): Promise { + 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 = {}, ): Promise { 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; diff --git a/src/core/features/h5p/classes/validator.ts b/src/core/features/h5p/classes/validator.ts index d72545705..0612bec96 100644 --- a/src/core/features/h5p/classes/validator.ts +++ b/src/core/features/h5p/classes/validator.ts @@ -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 }; diff --git a/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts index d3542f469..389472e77 100644 --- a/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts +++ b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts @@ -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(); @@ -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( diff --git a/src/core/features/h5p/components/h5p-player/core-h5p-player.html b/src/core/features/h5p/components/h5p-player/core-h5p-player.html index d5948426d..e30f0435e 100644 --- a/src/core/features/h5p/components/h5p-player/core-h5p-player.html +++ b/src/core/features/h5p/components/h5p-player/core-h5p-player.html @@ -9,4 +9,5 @@ - + diff --git a/src/core/features/h5p/components/h5p-player/h5p-player.scss b/src/core/features/h5p/components/h5p-player/h5p-player.scss index cbc743a2a..b8f1d82ea 100644 --- a/src/core/features/h5p/components/h5p-player/h5p-player.scss +++ b/src/core/features/h5p/components/h5p-player/h5p-player.scss @@ -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); diff --git a/src/core/features/h5p/components/h5p-player/h5p-player.ts b/src/core/features/h5p/components/h5p-player/h5p-player.ts index 41cc6ac3b..95853d05c 100644 --- a/src/core/features/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/features/h5p/components/h5p-player/h5p-player.ts @@ -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); diff --git a/src/core/features/h5p/services/database/h5p.ts b/src/core/features/h5p/services/database/h5p.ts index 4ff57daa5..92814e473 100644 --- a/src/core/features/h5p/services/database/h5p.ts +++ b/src/core/features/h5p/services/database/h5p.ts @@ -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 { 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]; diff --git a/src/core/features/h5p/services/h5p.ts b/src/core/features/h5p/services/h5p.ts index d3283b853..29b790d7b 100644 --- a/src/core/features/h5p/services/h5p.ts +++ b/src/core/features/h5p/services/h5p.ts @@ -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 { + 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. * diff --git a/src/core/features/h5p/services/handlers/pluginfile.ts b/src/core/features/h5p/services/handlers/pluginfile.ts index 7e04da07c..eca32e25f 100644 --- a/src/core/features/h5p/services/handlers/pluginfile.ts +++ b/src/core/features/h5p/services/handlers/pluginfile.ts @@ -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 { // 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 { - 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 = 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 { 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 { 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 { 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 { - return CoreH5PHelper.saveH5P(fileUrl, file, siteId, onProgress); + return CoreH5PHelper.saveH5P(fileUrl, file, options); } } diff --git a/src/core/lang.json b/src/core/lang.json index 890ed0331..a4a3981d3 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -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", diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index 5151214d2..ba760474a 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -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 { 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 { + async fixPluginfileURL(siteId: string, fileUrl: string, timemodified: number = 0): Promise { 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 & { 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. +}; diff --git a/src/core/services/plugin-file-delegate.ts b/src/core/services/plugin-file-delegate.ts index f7aa0012a..5ac8a0bdd 100644 --- a/src/core/services/plugin-file-delegate.ts +++ b/src/core/services/plugin-file-delegate.ts @@ -271,20 +271,18 @@ export class CorePluginFileDelegateService extends CoreDelegate { 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; + options?: CorePluginFileTreatDownloadedFileOptions, + ): Promise; } /** @@ -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 = { + siteId?: string; // Site ID. If not defined, current site. + onProgress?: CoreFilepoolOnProgressCallback; // 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. +}; diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index b761cc9f6..f3a713209 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -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.

Origin: ${origin}.

URL: ${siteUrl}.`, - }); + } }); } if (!CoreNetwork.isOnline()) { diff --git a/src/core/services/urlschemes.ts b/src/core/services/urlschemes.ts index 833bdcc8e..22d876bf8 100644 --- a/src/core/services/urlschemes.ts +++ b/src/core/services/urlschemes.ts @@ -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.

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.

URL: ${originalUrl}

Text to decode: ${url}` + `

Error: ${CoreErrorHelper.getErrorMessageFromError(err)}`, - })); + } })); } const data: CoreCustomURLSchemesParams = await CoreLoginHelper.validateBrowserSSOLogin(url); @@ -529,10 +529,10 @@ export class CoreCustomURLSchemesProvider { CoreLoginHelper.treatUserTokenError(error.data.siteUrl, 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.', - })); + } })); } } diff --git a/upgrade.txt b/upgrade.txt index a9429f01f..d171afb51 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -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 ===