diff --git a/src/addons/mod/h5pactivity/components/components.module.ts b/src/addons/mod/h5pactivity/components/components.module.ts
new file mode 100644
index 000000000..f0dc5a0ca
--- /dev/null
+++ b/src/addons/mod/h5pactivity/components/components.module.ts
@@ -0,0 +1,37 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { NgModule } from '@angular/core';
+
+import { CoreSharedModule } from '@/core/shared.module';
+import { CoreCourseComponentsModule } from '@features/course/components/components.module';
+import { AddonModH5PActivityIndexComponent } from './index';
+import { CoreH5PComponentsModule } from '@features/h5p/components/components.module';
+
+@NgModule({
+ declarations: [
+ AddonModH5PActivityIndexComponent,
+ ],
+ imports: [
+ CoreSharedModule,
+ CoreCourseComponentsModule,
+ CoreH5PComponentsModule,
+ ],
+ providers: [
+ ],
+ exports: [
+ AddonModH5PActivityIndexComponent,
+ ],
+})
+export class AddonModH5PActivityComponentsModule {}
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
new file mode 100644
index 000000000..38ded23d6
--- /dev/null
+++ b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'core.hasdatatosync' | translate: {$a: moduleName} }}
+
+
+
+
+
+
+
+
+ {{ 'core.h5p.offlinedisabled' | translate }} {{ 'addon.mod_h5pactivity.offlinedisabledwarning' | translate }}
+
+
+
+
+
+
+
+
+
+ {{ 'addon.mod_h5pactivity.previewmode' | translate }}
+
+
+
+
+
+
+ {{ stateMessage | translate }}
+
+
+
+
+ {{ 'addon.mod_h5pactivity.downloadh5pfile' | translate }}
+
+
+
+
+
+
+ {{ progressMessage | translate }}
+
+
+
+
+
+
+
+
diff --git a/src/addons/mod/h5pactivity/components/index/index.ts b/src/addons/mod/h5pactivity/components/index/index.ts
new file mode 100644
index 000000000..43c57aaa8
--- /dev/null
+++ b/src/addons/mod/h5pactivity/components/index/index.ts
@@ -0,0 +1,495 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Component, Optional, OnInit, OnDestroy } from '@angular/core';
+import { IonContent } from '@ionic/angular';
+
+import { CoreConstants } from '@/core/constants';
+import { CoreSite } from '@classes/site';
+import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
+import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
+import { CoreCourse } from '@features/course/services/course';
+import { CoreH5PDisplayOptions } from '@features/h5p/classes/core';
+import { CoreH5PHelper } from '@features/h5p/classes/helper';
+import { CoreH5P } from '@features/h5p/services/h5p';
+import { CoreXAPIOffline } from '@features/xapi/services/offline';
+import { CoreXAPI } from '@features/xapi/services/xapi';
+import { CoreApp } from '@services/app';
+import { CoreFilepool } from '@services/filepool';
+import { CoreNavigator } from '@services/navigator';
+import { CoreSites } from '@services/sites';
+import { CoreDomUtils } from '@services/utils/dom';
+import { CoreWSExternalFile } from '@services/ws';
+import { CoreEventObserver, CoreEvents } from '@singletons/events';
+import {
+ AddonModH5PActivity,
+ AddonModH5PActivityAccessInfo,
+ AddonModH5PActivityData,
+ AddonModH5PActivityProvider,
+} from '../../services/h5pactivity';
+import {
+ AddonModH5PActivitySync,
+ AddonModH5PActivitySyncProvider,
+ AddonModH5PActivitySyncResult,
+} from '../../services/h5pactivity-sync';
+
+/**
+ * Component that displays an H5P activity entry page.
+ */
+@Component({
+ selector: 'addon-mod-h5pactivity-index',
+ templateUrl: 'addon-mod-h5pactivity-index.html',
+})
+export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy {
+
+ component = AddonModH5PActivityProvider.COMPONENT;
+ moduleName = 'h5pactivity';
+
+ h5pActivity?: AddonModH5PActivityData; // The H5P activity object.
+ accessInfo?: AddonModH5PActivityAccessInfo; // Info about the user capabilities.
+ deployedFile?: CoreWSExternalFile; // The H5P deployed file.
+
+ stateMessage?: string; // Message about the file state.
+ downloading = false; // Whether the H5P file is being downloaded.
+ needsDownload = false; // Whether the file needs to be downloaded.
+ percentage?: string; // Download/unzip percentage.
+ showPercentage = false; // Whether to show the percentage.
+ progressMessage?: string; // Message about download/unzip.
+ playing = false; // Whether the package is being played.
+ displayOptions?: CoreH5PDisplayOptions; // Display options for the package.
+ onlinePlayerUrl?: string; // URL to play the package in online.
+ fileUrl?: string; // The fileUrl to use to play the package.
+ state?: string; // State of the file.
+ siteCanDownload = false;
+ trackComponent?: string; // Component for tracking.
+ hasOffline = false;
+ isOpeningPage = false;
+
+ protected fetchContentDefaultError = 'addon.mod_h5pactivity.errorgetactivity';
+ protected syncEventName = AddonModH5PActivitySyncProvider.AUTO_SYNCED;
+ protected site: CoreSite;
+ protected observer?: CoreEventObserver;
+ protected messageListenerFunction: (event: MessageEvent) => Promise;
+
+ constructor(
+ protected content?: IonContent,
+ @Optional() courseContentsPage?: CoreCourseContentsPage,
+ ) {
+ super('AddonModH5PActivityIndexComponent', content, courseContentsPage);
+
+ this.site = CoreSites.getCurrentSite()!;
+ this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.isOfflineDisabledInSite();
+
+ // Listen for messages from the iframe.
+ this.messageListenerFunction = this.onIframeMessage.bind(this);
+ window.addEventListener('message', this.messageListenerFunction);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ async ngOnInit(): Promise {
+ super.ngOnInit();
+
+ this.loadContent();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise {
+ try {
+ this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.module.id, {
+ siteId: this.siteId,
+ });
+
+ this.dataRetrieved.emit(this.h5pActivity);
+ this.description = this.h5pActivity.intro;
+ this.displayOptions = CoreH5PHelper.decodeDisplayOptions(this.h5pActivity.displayoptions);
+
+ if (sync) {
+ await this.syncActivity(showErrors);
+ }
+
+ await Promise.all([
+ this.checkHasOffline(),
+ this.fetchAccessInfo(),
+ this.fetchDeployedFileData(),
+ ]);
+
+ this.trackComponent = this.accessInfo?.cansubmit ? AddonModH5PActivityProvider.TRACK_COMPONENT : '';
+
+ if (this.h5pActivity.package && this.h5pActivity.package[0]) {
+ // The online player should use the original file, not the trusted one.
+ this.onlinePlayerUrl = CoreH5P.h5pPlayer.calculateOnlinePlayerUrl(
+ this.site.getURL(),
+ this.h5pActivity.package[0].fileurl,
+ this.displayOptions,
+ this.trackComponent,
+ );
+ }
+
+ if (!this.siteCanDownload || this.state == CoreConstants.DOWNLOADED) {
+ // Cannot download the file or already downloaded, play the package directly.
+ this.play();
+
+ } else if ((this.state == CoreConstants.NOT_DOWNLOADED || this.state == CoreConstants.OUTDATED) && CoreApp.isOnline() &&
+ this.deployedFile?.filesize && CoreFilepool.shouldDownload(this.deployedFile.filesize)) {
+ // Package is small, download it automatically. Don't block this function for this.
+ this.downloadAutomatically();
+ }
+ } finally {
+ this.fillContextMenu(refresh);
+ }
+ }
+
+ /**
+ * Fetch the access info and store it in the right variables.
+ *
+ * @return Promise resolved when done.
+ */
+ protected async checkHasOffline(): Promise {
+ this.hasOffline = await CoreXAPIOffline.contextHasStatements(this.h5pActivity!.context, this.siteId);
+ }
+
+ /**
+ * Fetch the access info and store it in the right variables.
+ *
+ * @return Promise resolved when done.
+ */
+ protected async fetchAccessInfo(): Promise {
+ this.accessInfo = await AddonModH5PActivity.getAccessInformation(this.h5pActivity!.id, {
+ cmId: this.module.id,
+ siteId: this.siteId,
+ });
+ }
+
+ /**
+ * Fetch the deployed file data if needed and store it in the right variables.
+ *
+ * @return Promise resolved when done.
+ */
+ protected async fetchDeployedFileData(): Promise {
+ if (!this.siteCanDownload) {
+ // Cannot download the file, no need to fetch the file data.
+ return;
+ }
+
+ this.deployedFile = await AddonModH5PActivity.getDeployedFile(this.h5pActivity!, {
+ displayOptions: this.displayOptions,
+ siteId: this.siteId,
+ });
+
+ this.fileUrl = this.deployedFile.fileurl;
+
+ // Listen for changes in the state.
+ const eventName = await CoreFilepool.getFileEventNameByUrl(this.site.getId(), this.deployedFile.fileurl);
+
+ if (!this.observer) {
+ this.observer = CoreEvents.on(eventName, () => {
+ this.calculateFileState();
+ });
+ }
+
+ await this.calculateFileState();
+ }
+
+ /**
+ * Calculate the state of the deployed file.
+ *
+ * @return Promise resolved when done.
+ */
+ protected async calculateFileState(): Promise {
+ this.state = await CoreFilepool.getFileStateByUrl(
+ this.site.getId(),
+ this.deployedFile!.fileurl,
+ this.deployedFile!.timemodified,
+ );
+
+ this.showFileState();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected invalidateContent(): Promise {
+ return AddonModH5PActivity.invalidateActivityData(this.courseId);
+ }
+
+ /**
+ * Displays some data based on the state of the main file.
+ */
+ protected async showFileState(): Promise {
+ if (this.state == CoreConstants.OUTDATED) {
+ this.stateMessage = 'addon.mod_h5pactivity.filestateoutdated';
+ this.needsDownload = true;
+ } else if (this.state == CoreConstants.NOT_DOWNLOADED) {
+ this.stateMessage = 'addon.mod_h5pactivity.filestatenotdownloaded';
+ this.needsDownload = true;
+ } else if (this.state == CoreConstants.DOWNLOADING) {
+ this.stateMessage = '';
+
+ if (!this.downloading) {
+ // It's being downloaded right now but the view isn't tracking it. "Restore" the download.
+ await this.downloadDeployedFile();
+
+ this.play();
+ }
+ } else {
+ this.stateMessage = '';
+ this.needsDownload = false;
+ }
+ }
+
+ /**
+ * Download the file and play it.
+ *
+ * @param event Click event.
+ * @return Promise resolved when done.
+ */
+ async downloadAndPlay(event?: MouseEvent): Promise {
+ event?.preventDefault();
+ event?.stopPropagation();
+
+ if (!CoreApp.isOnline()) {
+ CoreDomUtils.showErrorModal('core.networkerrormsg', true);
+
+ return;
+ }
+
+ try {
+ // Confirm the download if needed.
+ await CoreDomUtils.confirmDownloadSize({ size: this.deployedFile!.filesize!, total: true });
+
+ await this.downloadDeployedFile();
+
+ if (!this.isDestroyed) {
+ this.play();
+ }
+
+ } catch (error) {
+ if (CoreDomUtils.isCanceledError(error) || this.isDestroyed) {
+ // User cancelled or view destroyed, stop.
+ return;
+ }
+
+ CoreDomUtils.showErrorModalDefault(error, 'core.errordownloading', true);
+ }
+ }
+
+ /**
+ * Download the file automatically.
+ *
+ * @return Promise resolved when done.
+ */
+ protected async downloadAutomatically(): Promise {
+ try {
+ await this.downloadDeployedFile();
+
+ if (!this.isDestroyed) {
+ this.play();
+ }
+ } catch (error) {
+ CoreDomUtils.showErrorModalDefault(error, 'core.errordownloading', true);
+ }
+ }
+
+ /**
+ * Download athe H5P deployed file or restores an ongoing download.
+ *
+ * @return Promise resolved when done.
+ */
+ protected async downloadDeployedFile(): Promise {
+ this.downloading = true;
+ this.progressMessage = 'core.downloading';
+
+ try {
+ await CoreFilepool.downloadUrl(
+ this.site.getId(),
+ this.deployedFile!.fileurl,
+ false,
+ this.component,
+ this.componentId,
+ this.deployedFile!.timemodified,
+ (data: DownloadProgressData) => {
+ if (!data) {
+ return;
+ }
+
+ this.percentage = undefined;
+ this.showPercentage = false;
+
+ if (data.message) {
+ // Show a message.
+ this.progressMessage = data.message;
+ } else if (data.loaded !== undefined) {
+ // Downloading or unzipping.
+ const totalSize = this.progressMessage == 'core.downloading' ? this.deployedFile!.filesize : data.total;
+
+ if (totalSize !== undefined) {
+ const percentageNumber = (Number(data.loaded / totalSize) * 100);
+ this.percentage = percentageNumber.toFixed(1);
+ this.showPercentage = percentageNumber >= 0 && percentageNumber <= 100;
+ }
+ }
+ },
+ );
+
+ } finally {
+ this.progressMessage = undefined;
+ this.percentage = undefined;
+ this.showPercentage = false;
+ this.downloading = false;
+ }
+ }
+
+ /**
+ * Play the package.
+ */
+ play(): void {
+ this.playing = true;
+
+ // Mark the activity as viewed.
+ AddonModH5PActivity.logView(this.h5pActivity!.id, this.h5pActivity!.name, this.siteId);
+
+ CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata);
+ }
+
+ /**
+ * Go to view user events.
+ */
+ async viewMyAttempts(): Promise {
+ this.isOpeningPage = true;
+ const userId = CoreSites.getCurrentSiteUserId();
+
+ try {
+ await CoreNavigator.navigate(`userattempts/${userId}`);
+ } finally {
+ this.isOpeningPage = false;
+ }
+ }
+
+ /**
+ * Treat an iframe message event.
+ *
+ * @param event Event.
+ * @return Promise resolved when done.
+ */
+ protected async onIframeMessage(event: MessageEvent): Promise {
+ if (!event.data || !CoreXAPI.canPostStatementsInSite(this.site) || !this.isCurrentXAPIPost(event.data)) {
+ return;
+ }
+
+ try {
+ const options = {
+ offline: this.hasOffline,
+ courseId: this.courseId,
+ extra: this.h5pActivity!.name,
+ siteId: this.site.getId(),
+ };
+
+ const sent = await CoreXAPI.postStatements(
+ this.h5pActivity!.context,
+ event.data.component,
+ JSON.stringify(event.data.statements),
+ options,
+ );
+
+ this.hasOffline = !sent;
+
+ if (sent) {
+ try {
+ // Invalidate attempts.
+ await AddonModH5PActivity.invalidateUserAttempts(this.h5pActivity!.id, undefined, this.siteId);
+ } catch (error) {
+ // Ignore errors.
+ }
+ }
+ } catch (error) {
+ CoreDomUtils.showErrorModalDefault(error, 'Error sending tracking data.');
+ }
+ }
+
+ /**
+ * Check if an event is an XAPI post statement of the current activity.
+ *
+ * @param data Event data.
+ * @return Whether it's an XAPI post statement of the current activity.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ protected isCurrentXAPIPost(data: any): boolean {
+ if (data.environment != 'moodleapp' || data.context != 'h5p' || data.action != 'xapi_post_statement' || !data.statements) {
+ return false;
+ }
+
+ // Check the event belongs to this activity.
+ const trackingUrl = data.statements[0] && data.statements[0].object && data.statements[0].object.id;
+ if (!trackingUrl) {
+ return false;
+ }
+
+ if (!this.site.containsUrl(trackingUrl)) {
+ // The event belongs to another site, weird scenario. Maybe some JS running in background.
+ return false;
+ }
+
+ const match = trackingUrl.match(/xapi\/activity\/(\d+)/);
+
+ return match && match[1] == this.h5pActivity!.context;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected sync(): Promise {
+ return AddonModH5PActivitySync.syncActivity(this.h5pActivity!.context, this.site.getId());
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected autoSyncEventReceived(): void {
+ this.checkHasOffline();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ async gotoBlog(): Promise {
+ this.isOpeningPage = true;
+
+ try {
+ await super.gotoBlog();
+ } finally {
+ this.isOpeningPage = false;
+ }
+ }
+
+ /**
+ * Component destroyed.
+ */
+ ngOnDestroy(): void {
+ super.ngOnDestroy();
+
+ this.observer?.off();
+ window.removeEventListener('message', this.messageListenerFunction);
+ }
+
+}
+
+type DownloadProgressData = {
+ message?: string;
+ loaded?: number;
+ total?: number;
+};
diff --git a/src/addons/mod/h5pactivity/h5pactivity-lazy.module.ts b/src/addons/mod/h5pactivity/h5pactivity-lazy.module.ts
new file mode 100644
index 000000000..475e80f52
--- /dev/null
+++ b/src/addons/mod/h5pactivity/h5pactivity-lazy.module.ts
@@ -0,0 +1,41 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+
+import { CoreSharedModule } from '@/core/shared.module';
+import { CanLeaveGuard } from '@guards/can-leave';
+import { AddonModH5PActivityComponentsModule } from './components/components.module';
+import { AddonModH5PActivityIndexPage } from './pages/index/index';
+
+const routes: Routes = [
+ {
+ path: ':courseId/:cmId',
+ component: AddonModH5PActivityIndexPage,
+ canDeactivate: [CanLeaveGuard],
+ },
+];
+
+@NgModule({
+ imports: [
+ RouterModule.forChild(routes),
+ CoreSharedModule,
+ AddonModH5PActivityComponentsModule,
+ ],
+ declarations: [
+ AddonModH5PActivityIndexPage,
+ ],
+})
+export class AddonModH5PActivityLazyModule {}
diff --git a/src/addons/mod/h5pactivity/h5pactivity.module.ts b/src/addons/mod/h5pactivity/h5pactivity.module.ts
new file mode 100644
index 000000000..516b54977
--- /dev/null
+++ b/src/addons/mod/h5pactivity/h5pactivity.module.ts
@@ -0,0 +1,64 @@
+// (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 { APP_INITIALIZER, NgModule, Type } from '@angular/core';
+import { Routes } from '@angular/router';
+import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
+import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
+import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
+import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
+import { CoreCronDelegate } from '@services/cron';
+import { AddonModH5PActivityComponentsModule } from './components/components.module';
+import { AddonModH5PActivityProvider } from './services/h5pactivity';
+import { AddonModH5PActivitySyncProvider } from './services/h5pactivity-sync';
+import { AddonModH5PActivityIndexLinkHandler } from './services/handlers/index-link';
+import { AddonModH5PActivityModuleHandler, AddonModH5PActivityModuleHandlerService } from './services/handlers/module';
+import { AddonModH5PActivityPrefetchHandler } from './services/handlers/prefetch';
+import { AddonModH5PActivityReportLinkHandler } from './services/handlers/report-link';
+import { AddonModH5PActivitySyncCronHandler } from './services/handlers/sync-cron';
+
+// List of providers (without handlers).
+export const ADDON_MOD_H5P_ACTIVITY_SERVICES: Type[] = [
+ AddonModH5PActivityProvider,
+ AddonModH5PActivitySyncProvider,
+];
+
+const routes: Routes = [
+ {
+ path: AddonModH5PActivityModuleHandlerService.PAGE_NAME,
+ loadChildren: () => import('./h5pactivity-lazy.module').then(m => m.AddonModH5PActivityLazyModule),
+ },
+];
+
+@NgModule({
+ imports: [
+ CoreMainMenuTabRoutingModule.forChild(routes),
+ AddonModH5PActivityComponentsModule,
+ ],
+ providers: [
+ {
+ provide: APP_INITIALIZER,
+ multi: true,
+ deps: [],
+ useFactory: () => () => {
+ CoreCourseModuleDelegate.registerHandler(AddonModH5PActivityModuleHandler.instance);
+ CoreContentLinksDelegate.registerHandler(AddonModH5PActivityIndexLinkHandler.instance);
+ CoreContentLinksDelegate.registerHandler(AddonModH5PActivityReportLinkHandler.instance);
+ CoreCourseModulePrefetchDelegate.registerHandler(AddonModH5PActivityPrefetchHandler.instance);
+ CoreCronDelegate.register(AddonModH5PActivitySyncCronHandler.instance);
+ },
+ },
+ ],
+})
+export class AddonModH5PActivityModule {}
diff --git a/src/addons/mod/h5pactivity/lang.json b/src/addons/mod/h5pactivity/lang.json
new file mode 100644
index 000000000..3ac109635
--- /dev/null
+++ b/src/addons/mod/h5pactivity/lang.json
@@ -0,0 +1,36 @@
+{
+ "all_attempts": "All user attempts",
+ "answer_checked": "Answer checked",
+ "answer_correct": "Your answer is correct",
+ "answer_fail": "Incorrect answer",
+ "answer_incorrect": "Your answer is incorrect",
+ "answer_pass": "Correct answer",
+ "attempt": "Attempt",
+ "attempt_completion_no": "This attempt is not marked as completed",
+ "attempt_completion_yes": "This attempt is completed",
+ "attempt_success_fail": "Fail",
+ "attempt_success_pass": "Pass",
+ "attempt_success_unknown": "Not reported",
+ "attempts_none": "This user has no attempts to display.",
+ "completion": "Completion",
+ "downloadh5pfile": "Download H5P file",
+ "duration": "Duration",
+ "errorgetactivity": "Error getting H5P activity data.",
+ "filestatenotdownloaded": "The H5P package is not downloaded. You need to download it to be able to use it.",
+ "filestateoutdated": "The H5P package has been modified since the last download. You need to download it again to be able to use it.",
+ "maxscore": "Max score",
+ "modulenameplural": "H5P",
+ "myattempts": "My attempts",
+ "no_compatible_track": "This interaction ({{$a}}) does not provide tracking information or the tracking\n provided is not compatible with the current activity version.",
+ "offlinedisabledwarning": "You need to be online to view the H5P package.",
+ "outcome": "Outcome",
+ "previewmode": "This content is displayed in preview mode. No attempt tracking will be stored.",
+ "result_fill-in": "Fill-in text",
+ "result_other": "Unknown interaction type",
+ "review_my_attempts": "View my attempts",
+ "score": "Score",
+ "score_out_of": "{{$a.rawscore}} out of {{$a.maxscore}}",
+ "startdate": "Start date",
+ "totalscore": "Total score",
+ "viewattempt": "View attempt {{$a}}"
+}
\ No newline at end of file
diff --git a/src/addons/mod/h5pactivity/pages/index/index.html b/src/addons/mod/h5pactivity/pages/index/index.html
new file mode 100644
index 000000000..9f00ce622
--- /dev/null
+++ b/src/addons/mod/h5pactivity/pages/index/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/mod/h5pactivity/pages/index/index.ts b/src/addons/mod/h5pactivity/pages/index/index.ts
new file mode 100644
index 000000000..db3836c8a
--- /dev/null
+++ b/src/addons/mod/h5pactivity/pages/index/index.ts
@@ -0,0 +1,52 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Component, ViewChild } from '@angular/core';
+
+import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page';
+import { CanLeave } from '@guards/can-leave';
+import { CoreDomUtils } from '@services/utils/dom';
+import { Translate } from '@singletons';
+import { AddonModH5PActivityIndexComponent } from '../../components/index';
+
+/**
+ * Page that displays an H5P activity.
+ */
+@Component({
+ selector: 'page-addon-mod-h5pactivity-index',
+ templateUrl: 'index.html',
+})
+export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPage
+ implements CanLeave {
+
+ @ViewChild(AddonModH5PActivityIndexComponent) activityComponent?: AddonModH5PActivityIndexComponent;
+
+ /**
+ * @inheritdoc
+ */
+ async canLeave(): Promise {
+ if (!this.activityComponent || !this.activityComponent.playing || this.activityComponent.isOpeningPage) {
+ return true;
+ }
+
+ try {
+ await CoreDomUtils.showConfirm(Translate.instant('core.confirmleaveunknownchanges'));
+
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+}
diff --git a/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts b/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts
new file mode 100644
index 000000000..5a7c4f0d1
--- /dev/null
+++ b/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts
@@ -0,0 +1,228 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+
+import { CoreNetworkError } from '@classes/errors/network-error';
+import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
+import { CoreCourse } from '@features/course/services/course';
+import { CoreCourseLogHelper } from '@features/course/services/log-helper';
+import { CoreXAPIOffline } from '@features/xapi/services/offline';
+import { CoreXAPI } from '@features/xapi/services/xapi';
+import { CoreApp } from '@services/app';
+import { CoreSites } from '@services/sites';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreUtils } from '@services/utils/utils';
+import { makeSingleton, Translate } from '@singletons';
+import { CoreEvents } from '@singletons/events';
+import { AddonModH5PActivity, AddonModH5PActivityProvider } from './h5pactivity';
+
+/**
+ * Service to sync H5P activities.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseProvider {
+
+ static readonly AUTO_SYNCED = 'addon_mod_h5pactivity_autom_synced';
+
+ protected componentTranslate?: string;
+
+ constructor() {
+ super('AddonModH5PActivitySyncProvider');
+ }
+
+ /**
+ * Get component name translated.
+ *
+ * @return Component name translated.
+ */
+ protected getComponentTranslate(): string {
+ if (!this.componentTranslate) {
+ this.componentTranslate = CoreCourse.translateModuleName('h5pactivity');
+ }
+
+ return this.componentTranslate;
+ }
+
+ /**
+ * Try to synchronize all the H5P activities in a certain site or in all sites.
+ *
+ * @param siteId Site ID to sync. If not defined, sync all sites.
+ * @param force Wether to force sync not depending on last execution.
+ * @return Promise resolved if sync is successful, rejected if sync fails.
+ */
+ syncAllActivities(siteId?: string, force?: boolean): Promise {
+ return this.syncOnSites('H5P activities', this.syncAllActivitiesFunc.bind(this, !!force), siteId);
+ }
+
+ /**
+ * Sync all H5P activities on a site.
+ *
+ * @param force Wether to force sync not depending on last execution.
+ * @param siteId Site ID to sync. If not defined, sync all sites.
+ * @return Promise resolved if sync is successful, rejected if sync fails.
+ */
+ protected async syncAllActivitiesFunc(force: boolean, siteId?: string): Promise {
+ const entries = await CoreXAPIOffline.getAllStatements(siteId);
+
+ // Sync all responses.
+ const promises = entries.map(async (response) => {
+ const result = await (force ? this.syncActivity(response.contextid, siteId) :
+ this.syncActivityIfNeeded(response.contextid, siteId));
+
+ if (result?.updated) {
+ // Sync successful, send event.
+ CoreEvents.trigger(AddonModH5PActivitySyncProvider.AUTO_SYNCED, {
+ contextId: response.contextid,
+ warnings: result.warnings,
+ }, siteId);
+ }
+ });
+
+ await Promise.all(promises);
+ }
+
+ /**
+ * Sync an H5P activity only if a certain time has passed since the last time.
+ *
+ * @param contextId Context ID of the activity.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the activity is synced or it doesn't need to be synced.
+ */
+ async syncActivityIfNeeded(contextId: number, siteId?: string): Promise {
+ const needed = await this.isSyncNeeded(contextId, siteId);
+
+ if (needed) {
+ return this.syncActivity(contextId, siteId);
+ }
+ }
+
+ /**
+ * Synchronize an H5P activity. If it's already being synced it will reuse the same promise.
+ *
+ * @param contextId Context ID of the activity.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved if sync is successful, rejected otherwise.
+ */
+ syncActivity(contextId: number, siteId?: string): Promise {
+ siteId = siteId || CoreSites.getCurrentSiteId();
+
+ if (!CoreApp.isOnline()) {
+ // Cannot sync in offline.
+ throw new CoreNetworkError();
+ }
+
+ if (this.isSyncing(contextId, siteId)) {
+ // There's already a sync ongoing for this discussion, return the promise.
+ return this.getOngoingSync(contextId, siteId)!;
+ }
+
+ return this.addOngoingSync(contextId, this.syncActivityData(contextId, siteId), siteId);
+ }
+
+ /**
+ * Synchronize an H5P activity.
+ *
+ * @param contextId Context ID of the activity.
+ * @param siteId Site ID.
+ * @return Promise resolved if sync is successful, rejected otherwise.
+ */
+ protected async syncActivityData(contextId: number, siteId: string): Promise {
+
+ this.logger.debug(`Try to sync H5P activity with context ID '${contextId}'`);
+
+ const result: AddonModH5PActivitySyncResult = {
+ warnings: [],
+ updated: false,
+ };
+
+ // Get all the statements stored for the activity.
+ const entries = await CoreXAPIOffline.getContextStatements(contextId, siteId);
+
+ if (!entries || !entries.length) {
+ // Nothing to sync.
+ await this.setSyncTime(contextId, siteId);
+
+ return result;
+ }
+
+ // Get the activity instance.
+ const courseId = entries[0].courseid!;
+
+ const h5pActivity = await AddonModH5PActivity.getH5PActivityByContextId(courseId, contextId, { siteId });
+
+ // Sync offline logs.
+ await CoreUtils.ignoreErrors(
+ CoreCourseLogHelper.syncActivity(AddonModH5PActivityProvider.COMPONENT, h5pActivity.id, siteId),
+ );
+
+ // Send the statements in order.
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+
+ try {
+ await CoreXAPI.postStatementsOnline(entry.component, entry.statements, siteId);
+
+ result.updated = true;
+
+ await CoreXAPIOffline.deleteStatements(entry.id, siteId);
+ } catch (error) {
+ if (!CoreUtils.isWebServiceError(error)) {
+ throw error;
+ }
+
+ // The WebService has thrown an error, this means that statements cannot be submitted. Delete them.
+ result.updated = true;
+
+ await CoreXAPIOffline.deleteStatements(entry.id, siteId);
+
+ // Responses deleted, add a warning.
+ result.warnings.push(Translate.instant('core.warningofflinedatadeleted', {
+ component: this.componentTranslate,
+ name: entry.extra,
+ error: CoreTextUtils.getErrorMessageFromError(error),
+ }));
+ }
+ }
+
+ if (result.updated) {
+ // Data has been sent to server, invalidate attempts.
+ await CoreUtils.ignoreErrors(AddonModH5PActivity.invalidateUserAttempts(h5pActivity.id, undefined, siteId));
+ }
+
+ // Sync finished, set sync time.
+ await this.setSyncTime(contextId, siteId);
+
+ return result;
+ }
+
+}
+
+export const AddonModH5PActivitySync = makeSingleton(AddonModH5PActivitySyncProvider);
+
+/**
+ * Sync result.
+ */
+export type AddonModH5PActivitySyncResult = {
+ updated: boolean;
+ warnings: string[];
+};
+
+/**
+ * Data passed to AUTO_SYNC event.
+ */
+export type AddonModH5PActivityAutoSyncData = {
+ contextId: number;
+ warnings: string[];
+};
diff --git a/src/addons/mod/h5pactivity/services/h5pactivity.ts b/src/addons/mod/h5pactivity/services/h5pactivity.ts
new file mode 100644
index 000000000..3b9d8c7d3
--- /dev/null
+++ b/src/addons/mod/h5pactivity/services/h5pactivity.ts
@@ -0,0 +1,858 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+
+import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites';
+import { CoreWSExternalWarning, CoreWSExternalFile } from '@services/ws';
+import { CoreTimeUtils } from '@services/utils/time';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
+import { CoreCourseLogHelper } from '@features/course/services/log-helper';
+import { CoreH5P } from '@features/h5p/services/h5p';
+import { CoreH5PDisplayOptions } from '@features/h5p/classes/core';
+import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
+import { makeSingleton, Translate } from '@singletons/index';
+import { CoreWSError } from '@classes/errors/wserror';
+import { CoreError } from '@classes/errors/error';
+import { AddonModH5PActivityAutoSyncData, AddonModH5PActivitySyncProvider } from './h5pactivity-sync';
+
+const ROOT_CACHE_KEY = 'mmaModH5PActivity:';
+
+/**
+ * Service that provides some features for H5P activity.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModH5PActivityProvider {
+
+ static readonly COMPONENT = 'mmaModH5PActivity';
+ static readonly TRACK_COMPONENT = 'mod_h5pactivity'; // Component for tracking.
+
+ /**
+ * Format an attempt's data.
+ *
+ * @param attempt Attempt to format.
+ * @return Formatted attempt.
+ */
+ protected formatAttempt(attempt: AddonModH5PActivityWSAttempt): AddonModH5PActivityAttempt {
+ const formattedAttempt: AddonModH5PActivityAttempt = attempt;
+
+ formattedAttempt.timecreated = attempt.timecreated * 1000; // Convert to milliseconds.
+ formattedAttempt.timemodified = attempt.timemodified * 1000; // Convert to milliseconds.
+ formattedAttempt.success = formattedAttempt.success ?? null;
+
+ if (!attempt.duration) {
+ formattedAttempt.durationReadable = '-';
+ formattedAttempt.durationCompact = '-';
+ } else {
+ formattedAttempt.durationReadable = CoreTimeUtils.formatTime(attempt.duration);
+ formattedAttempt.durationCompact = CoreTimeUtils.formatDurationShort(attempt.duration);
+ }
+
+ return formattedAttempt;
+ }
+
+ /**
+ * Format attempt data and results.
+ *
+ * @param attempt Attempt and results to format.
+ */
+ protected formatAttemptResults(attempt: AddonModH5PActivityWSAttemptResults): AddonModH5PActivityAttemptResults {
+ const formattedAttempt: AddonModH5PActivityAttemptResults = this.formatAttempt(attempt);
+
+ formattedAttempt.results = formattedAttempt.results?.map((result) => this.formatResult(result));
+
+ return formattedAttempt;
+ }
+
+ /**
+ * Format the attempts of a user.
+ *
+ * @param data Data to format.
+ * @return Formatted data.
+ */
+ protected formatUserAttempts(data: AddonModH5PActivityWSUserAttempts): AddonModH5PActivityUserAttempts {
+ const formatted: AddonModH5PActivityUserAttempts = data;
+
+ formatted.attempts = formatted.attempts.map((attempt) => this.formatAttempt(attempt));
+
+ if (formatted.scored) {
+ formatted.scored.attempts = formatted.scored.attempts.map((attempt) => this.formatAttempt(attempt));
+ }
+
+ return formatted;
+ }
+
+ /**
+ * Format an attempt's result.
+ *
+ * @param result Result to format.
+ */
+ protected formatResult(result: AddonModH5PActivityWSResult): AddonModH5PActivityWSResult {
+ result.timecreated = result.timecreated * 1000; // Convert to milliseconds.
+
+ return result;
+ }
+
+ /**
+ * Get cache key for access information WS calls.
+ *
+ * @param id H5P activity ID.
+ * @return Cache key.
+ */
+ protected getAccessInformationCacheKey(id: number): string {
+ return ROOT_CACHE_KEY + 'accessInfo:' + id;
+ }
+
+ /**
+ * Get access information for a given H5P activity.
+ *
+ * @param id H5P activity ID.
+ * @param options Other options.
+ * @return Promise resolved with the data.
+ */
+ async getAccessInformation(id: number, options: CoreCourseCommonModWSOptions = {}): Promise {
+ const site = await CoreSites.getSite(options.siteId);
+
+ const params: AddonModH5pactivityGetH5pactivityAccessInformationWSParams = {
+ h5pactivityid: id,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getAccessInformationCacheKey(id),
+ updateFrequency: CoreSite.FREQUENCY_OFTEN,
+ component: AddonModH5PActivityProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ return site.read('mod_h5pactivity_get_h5pactivity_access_information', params, preSets);
+ }
+
+ /**
+ * Get attempt results for all user attempts.
+ *
+ * @param id Activity ID.
+ * @param options Other options.
+ * @return Promise resolved with the results of the attempt.
+ */
+ async getAllAttemptsResults(
+ id: number,
+ options?: AddonModH5PActivityGetAttemptResultsOptions,
+ ): Promise {
+
+ const userAttempts = await this.getUserAttempts(id, options);
+
+ const attemptIds = userAttempts.attempts.map((attempt) => attempt.id);
+
+ if (attemptIds.length) {
+ // Get all the attempts with a single call.
+ return this.getAttemptsResults(id, attemptIds, options);
+ } else {
+ // No attempts.
+ return {
+ activityid: id,
+ attempts: [],
+ warnings: [],
+ };
+ }
+ }
+
+ /**
+ * Get cache key for results WS calls.
+ *
+ * @param id Instance ID.
+ * @param attemptsIds Attempts IDs.
+ * @return Cache key.
+ */
+ protected getAttemptResultsCacheKey(id: number, attemptsIds: number[]): string {
+ return this.getAttemptResultsCommonCacheKey(id) + ':' + JSON.stringify(attemptsIds);
+ }
+
+ /**
+ * Get common cache key for results WS calls.
+ *
+ * @param id Instance ID.
+ * @return Cache key.
+ */
+ protected getAttemptResultsCommonCacheKey(id: number): string {
+ return ROOT_CACHE_KEY + 'results:' + id;
+ }
+
+ /**
+ * Get attempt results.
+ *
+ * @param id Activity ID.
+ * @param attemptId Attempt ID.
+ * @param options Other options.
+ * @return Promise resolved with the results of the attempt.
+ */
+ async getAttemptResults(
+ id: number,
+ attemptId: number,
+ options?: AddonModH5PActivityGetAttemptResultsOptions,
+ ): Promise {
+
+ options = options || {};
+
+ const site = await CoreSites.getSite(options.siteId);
+
+ const params: AddonModH5pactivityGetResultsWSParams = {
+ h5pactivityid: id,
+ attemptids: [attemptId],
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getAttemptResultsCacheKey(id, params.attemptids!),
+ updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
+ component: AddonModH5PActivityProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ try {
+ const response = await site.read(
+ 'mod_h5pactivity_get_results',
+ params,
+ preSets,
+ );
+
+ if (response.warnings?.[0]) {
+ throw new CoreWSError(response.warnings[0]); // Cannot view attempt.
+ }
+
+ return this.formatAttemptResults(response.attempts[0]);
+ } catch (error) {
+ if (CoreUtils.isWebServiceError(error)) {
+ throw error;
+ }
+
+ // Check if the full list of results is cached. If so, get the results from there.
+ const cacheOptions: AddonModH5PActivityGetAttemptResultsOptions = {
+ ...options, // Include all the original options.
+ readingStrategy: CoreSitesReadingStrategy.OnlyCache,
+ };
+
+ const attemptsResults = await AddonModH5PActivity.getAllAttemptsResults(id, cacheOptions);
+
+ const attempt = attemptsResults.attempts.find((attempt) => attempt.id == attemptId);
+
+ if (!attempt) {
+ throw error;
+ }
+
+ return attempt;
+ }
+ }
+
+ /**
+ * Get attempts results.
+ *
+ * @param id Activity ID.
+ * @param attemptsIds Attempts IDs.
+ * @param options Other options.
+ * @return Promise resolved with all the attempts.
+ */
+ async getAttemptsResults(
+ id: number,
+ attemptsIds: number[],
+ options?: AddonModH5PActivityGetAttemptResultsOptions,
+ ): Promise {
+
+ options = options || {};
+
+ const site = await CoreSites.getSite(options.siteId);
+
+ const params: AddonModH5pactivityGetResultsWSParams = {
+ h5pactivityid: id,
+ attemptids: attemptsIds,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getAttemptResultsCommonCacheKey(id),
+ updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
+ component: AddonModH5PActivityProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const response = await site.read(
+ 'mod_h5pactivity_get_results',
+ params,
+ preSets,
+ );
+
+ response.attempts = response.attempts.map((attempt) => this.formatAttemptResults(attempt));
+
+ return response;
+ }
+
+ /**
+ * Get deployed file from an H5P activity instance.
+ *
+ * @param h5pActivity Activity instance.
+ * @param options Options
+ * @return Promise resolved with the file.
+ */
+ async getDeployedFile(
+ h5pActivity: AddonModH5PActivityData,
+ options?: AddonModH5PActivityGetDeployedFileOptions,
+ ): Promise {
+
+ if (h5pActivity.deployedfile) {
+ // File already deployed and still valid, use this one.
+ return h5pActivity.deployedfile;
+ }
+
+ if (!h5pActivity.package || !h5pActivity.package[0]) {
+ // Shouldn't happen.
+ throw new CoreError('No H5P package found.');
+ }
+
+ options = options || {};
+
+ // Deploy the file in the server.
+ return CoreH5P.getTrustedH5PFile(
+ h5pActivity.package[0].fileurl,
+ options.displayOptions,
+ options.ignoreCache,
+ options.siteId,
+ );
+ }
+
+ /**
+ * Get cache key for H5P activity data WS calls.
+ *
+ * @param courseId Course ID.
+ * @return Cache key.
+ */
+ protected getH5PActivityDataCacheKey(courseId: number): string {
+ return ROOT_CACHE_KEY + 'h5pactivity:' + courseId;
+ }
+
+ /**
+ * Get an H5P activity with key=value. If more than one is found, only the first will be returned.
+ *
+ * @param courseId Course ID.
+ * @param key Name of the property to check.
+ * @param value Value to search.
+ * @param options Other options.
+ * @return Promise resolved with the activity data.
+ */
+ protected async getH5PActivityByField(
+ courseId: number,
+ key: string,
+ value: unknown,
+ options: CoreSitesCommonWSOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.getSite(options.siteId);
+
+ const params: AddonModH5pactivityGetByCoursesWSParams = {
+ courseids: [courseId],
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getH5PActivityDataCacheKey(courseId),
+ updateFrequency: CoreSite.FREQUENCY_RARELY,
+ component: AddonModH5PActivityProvider.COMPONENT,
+ ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const response = await site.read(
+ 'mod_h5pactivity_get_h5pactivities_by_courses',
+ params,
+ preSets,
+ );
+
+ const currentActivity = response.h5pactivities.find((h5pActivity) => h5pActivity[key] == value);
+
+ if (currentActivity) {
+ return currentActivity;
+ }
+
+ throw new CoreError(Translate.instant('addon.mod_h5pactivity.errorgetactivity'));
+ }
+
+ /**
+ * Get an H5P activity by module ID.
+ *
+ * @param courseId Course ID.
+ * @param cmId Course module ID.
+ * @param options Other options.
+ * @return Promise resolved with the activity data.
+ */
+ getH5PActivity(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise {
+ return this.getH5PActivityByField(courseId, 'coursemodule', cmId, options);
+ }
+
+ /**
+ * Get an H5P activity by context ID.
+ *
+ * @param courseId Course ID.
+ * @param contextId Context ID.
+ * @param options Other options.
+ * @return Promise resolved with the activity data.
+ */
+ getH5PActivityByContextId(
+ courseId: number,
+ contextId: number,
+ options: CoreSitesCommonWSOptions = {},
+ ): Promise {
+ return this.getH5PActivityByField(courseId, 'context', contextId, options);
+ }
+
+ /**
+ * Get an H5P activity by instance ID.
+ *
+ * @param courseId Course ID.
+ * @param id Instance ID.
+ * @param options Other options.
+ * @return Promise resolved with the activity data.
+ */
+ getH5PActivityById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise {
+ return this.getH5PActivityByField(courseId, 'id', id, options);
+ }
+
+ /**
+ * Get cache key for attemps WS calls.
+ *
+ * @param id Instance ID.
+ * @param userIds User IDs.
+ * @return Cache key.
+ */
+ protected getUserAttemptsCacheKey(id: number, userIds: number[]): string {
+ return this.getUserAttemptsCommonCacheKey(id) + ':' + JSON.stringify(userIds);
+ }
+
+ /**
+ * Get common cache key for attempts WS calls.
+ *
+ * @param id Instance ID.
+ * @return Cache key.
+ */
+ protected getUserAttemptsCommonCacheKey(id: number): string {
+ return ROOT_CACHE_KEY + 'attempts:' + id;
+ }
+
+ /**
+ * Get attempts of a certain user.
+ *
+ * @param id Activity ID.
+ * @param options Other options.
+ * @return Promise resolved with the attempts of the user.
+ */
+ async getUserAttempts(
+ id: number,
+ options: AddonModH5PActivityGetAttemptsOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.getSite(options.siteId);
+
+ const params: AddonModH5pactivityGetAttemptsWSParams = {
+ h5pactivityid: id,
+ userids: [options.userId || site.getUserId()],
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getUserAttemptsCacheKey(id, params.userids!),
+ updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
+ component: AddonModH5PActivityProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const response = await site.read('mod_h5pactivity_get_attempts', params, preSets);
+
+ if (response.warnings?.[0]) {
+ throw new CoreWSError(response.warnings[0]); // Cannot view user attempts.
+ }
+
+ return this.formatUserAttempts(response.usersattempts[0]);
+ }
+
+ /**
+ * Invalidates access information.
+ *
+ * @param id H5P activity ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAccessInformation(id: number, siteId?: string): Promise {
+
+ const site = await CoreSites.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(id));
+ }
+
+ /**
+ * Invalidates H5P activity data.
+ *
+ * @param courseId Course ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateActivityData(courseId: number, siteId?: string): Promise {
+ const site = await CoreSites.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getH5PActivityDataCacheKey(courseId));
+ }
+
+ /**
+ * Invalidates all attempts results for H5P activity.
+ *
+ * @param id Activity ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAllResults(id: number, siteId?: string): Promise {
+ const site = await CoreSites.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getAttemptResultsCommonCacheKey(id));
+ }
+
+ /**
+ * Invalidates results of a certain attempt for H5P activity.
+ *
+ * @param id Activity ID.
+ * @param attemptId Attempt ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAttemptResults(id: number, attemptId: number, siteId?: string): Promise {
+ const site = await CoreSites.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getAttemptResultsCacheKey(id, [attemptId]));
+ }
+
+ /**
+ * Invalidates all users attempts for H5P activity.
+ *
+ * @param id Activity ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAllUserAttempts(id: number, siteId?: string): Promise {
+ const site = await CoreSites.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getUserAttemptsCommonCacheKey(id));
+ }
+
+ /**
+ * Invalidates attempts of a certain user for H5P activity.
+ *
+ * @param id Activity ID.
+ * @param userId User ID. If not defined, current user in the site.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateUserAttempts(id: number, userId?: number, siteId?: string): Promise {
+ const site = await CoreSites.getSite(siteId);
+
+ userId = userId || site.getUserId();
+
+ await site.invalidateWsCacheForKey(this.getUserAttemptsCacheKey(id, [userId]));
+ }
+
+ /**
+ * Delete launcher.
+ *
+ * @return Promise resolved when the launcher file is deleted.
+ */
+ async isPluginEnabled(siteId?: string): Promise {
+ const site = await CoreSites.getSite(siteId);
+
+ return site.wsAvailable('mod_h5pactivity_get_h5pactivities_by_courses');
+ }
+
+ /**
+ * Report an H5P activity as being viewed.
+ *
+ * @param id H5P activity ID.
+ * @param name Name of the activity.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the WS call is successful.
+ */
+ logView(id: number, name?: string, siteId?: string): Promise {
+ const params: AddonModH5pactivityViewH5pactivityWSParams = {
+ h5pactivityid: id,
+ };
+
+ return CoreCourseLogHelper.logSingle(
+ 'mod_h5pactivity_view_h5pactivity',
+ params,
+ AddonModH5PActivityProvider.COMPONENT,
+ id,
+ name,
+ 'h5pactivity',
+ {},
+ siteId,
+ );
+ }
+
+}
+
+export const AddonModH5PActivity = makeSingleton(AddonModH5PActivityProvider);
+
+/**
+ * Basic data for an H5P activity, exported by Moodle class h5pactivity_summary_exporter.
+ */
+export type AddonModH5PActivityData = {
+ id: number; // The primary key of the record.
+ course: number; // Course id this h5p activity is part of.
+ name: string; // The name of the activity module instance.
+ timecreated?: number; // Timestamp of when the instance was added to the course.
+ timemodified?: number; // Timestamp of when the instance was last modified.
+ intro: string; // H5P activity description.
+ introformat: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ grade?: number; // The maximum grade for submission.
+ displayoptions: number; // H5P Button display options.
+ enabletracking: number; // Enable xAPI tracking.
+ grademethod: number; // Which H5P attempt is used for grading.
+ contenthash?: string; // Sha1 hash of file content.
+ coursemodule: number; // Coursemodule.
+ context: number; // Context ID.
+ introfiles: CoreWSExternalFile[];
+ package: CoreWSExternalFile[];
+ deployedfile?: {
+ filename?: string; // File name.
+ filepath?: string; // File path.
+ filesize?: number; // File size.
+ fileurl: string; // Downloadable file url.
+ timemodified?: number; // Time modified.
+ mimetype?: string; // File mime type.
+ };
+};
+
+/**
+ * Params of mod_h5pactivity_get_h5pactivities_by_courses WS.
+ */
+export type AddonModH5pactivityGetByCoursesWSParams = {
+ courseids?: number[]; // Array of course ids.
+};
+
+/**
+ * Data returned by mod_h5pactivity_get_h5pactivities_by_courses WS.
+ */
+export type AddonModH5pactivityGetByCoursesWSResponse = {
+ h5pactivities: AddonModH5PActivityData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_h5pactivity_get_h5pactivity_access_information WS.
+ */
+export type AddonModH5pactivityGetH5pactivityAccessInformationWSParams = {
+ h5pactivityid: number; // H5p activity instance id.
+};
+
+/**
+ * Data returned by mod_h5pactivity_get_h5pactivity_access_information WS.
+ */
+export type AddonModH5pactivityGetH5pactivityAccessInformationWSResponse = {
+ warnings?: CoreWSExternalWarning[];
+ canview?: boolean; // Whether the user has the capability mod/h5pactivity:view allowed.
+ canaddinstance?: boolean; // Whether the user has the capability mod/h5pactivity:addinstance allowed.
+ cansubmit?: boolean; // Whether the user has the capability mod/h5pactivity:submit allowed.
+ canreviewattempts?: boolean; // Whether the user has the capability mod/h5pactivity:reviewattempts allowed.
+};
+
+/**
+ * Result of WS mod_h5pactivity_get_h5pactivity_access_information.
+ */
+export type AddonModH5PActivityAccessInfo = AddonModH5pactivityGetH5pactivityAccessInformationWSResponse;
+
+/**
+ * Params of mod_h5pactivity_get_attempts WS.
+ */
+export type AddonModH5pactivityGetAttemptsWSParams = {
+ h5pactivityid: number; // H5p activity instance id.
+ userids?: number[]; // User ids.
+};
+
+/**
+ * Data returned by mod_h5pactivity_get_attempts WS.
+ */
+export type AddonModH5pactivityGetAttemptsWSResponse = {
+ activityid: number; // Activity course module ID.
+ usersattempts: AddonModH5PActivityWSUserAttempts[]; // The complete users attempts list.
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_h5pactivity_get_results WS.
+ */
+export type AddonModH5pactivityGetResultsWSParams = {
+ h5pactivityid: number; // H5p activity instance id.
+ attemptids?: number[]; // Attempt ids.
+};
+
+/**
+ * Data returned by mod_h5pactivity_get_results WS.
+ */
+export type AddonModH5pactivityGetResultsWSResponse = {
+ activityid: number; // Activity course module ID.
+ attempts: AddonModH5PActivityWSAttemptResults[]; // The complete attempts list.
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Attempts results with some calculated data.
+ */
+export type AddonModH5PActivityAttemptsResults = Omit & {
+ attempts: AddonModH5PActivityAttemptResults[]; // The complete attempts list.
+};
+
+/**
+ * Attempts data for a user as returned by the WS mod_h5pactivity_get_attempts.
+ */
+export type AddonModH5PActivityWSUserAttempts = {
+ userid: number; // The user id.
+ attempts: AddonModH5PActivityWSAttempt[]; // The complete attempts list.
+ scored?: { // Attempts used to grade the activity.
+ title: string; // Scored attempts title.
+ grademethod: string; // Scored attempts title.
+ attempts: AddonModH5PActivityWSAttempt[]; // List of the grading attempts.
+ };
+};
+
+/**
+ * Attempt data as returned by the WS mod_h5pactivity_get_attempts.
+ */
+export type AddonModH5PActivityWSAttempt = {
+ id: number; // ID of the context.
+ h5pactivityid: number; // ID of the H5P activity.
+ userid: number; // ID of the user.
+ timecreated: number; // Attempt creation.
+ timemodified: number; // Attempt modified.
+ attempt: number; // Attempt number.
+ rawscore: number; // Attempt score value.
+ maxscore: number; // Attempt max score.
+ duration: number; // Attempt duration in seconds.
+ completion?: number; // Attempt completion.
+ success?: number | null; // Attempt success.
+ scaled: number; // Attempt scaled.
+};
+
+/**
+ * Attempt and results data as returned by the WS mod_h5pactivity_get_results.
+ */
+export type AddonModH5PActivityWSAttemptResults = AddonModH5PActivityWSAttempt & {
+ results?: AddonModH5PActivityWSResult[]; // The results of the attempt.
+};
+
+/**
+ * Attempt result data as returned by the WS mod_h5pactivity_get_results.
+ */
+export type AddonModH5PActivityWSResult = {
+ id: number; // ID of the context.
+ attemptid: number; // ID of the H5P attempt.
+ subcontent: string; // Subcontent identifier.
+ timecreated: number; // Result creation.
+ interactiontype: string; // Interaction type.
+ description: string; // Result description.
+ content?: string; // Result extra content.
+ rawscore: number; // Result score value.
+ maxscore: number; // Result max score.
+ duration?: number; // Result duration in seconds.
+ completion?: number; // Result completion.
+ success?: number | null; // Result success.
+ optionslabel?: string; // Label used for result options.
+ correctlabel?: string; // Label used for correct answers.
+ answerlabel?: string; // Label used for user answers.
+ track?: boolean; // If the result has valid track information.
+ options?: { // The statement options.
+ description: string; // Option description.
+ id: number; // Option identifier.
+ correctanswer: AddonModH5PActivityWSResultAnswer; // The option correct answer.
+ useranswer: AddonModH5PActivityWSResultAnswer; // The option user answer.
+ }[];
+};
+
+/**
+ * Result answer as returned by the WS mod_h5pactivity_get_results.
+ */
+export type AddonModH5PActivityWSResultAnswer = {
+ answer?: string; // Option text value.
+ correct?: boolean; // If has to be displayed as correct.
+ incorrect?: boolean; // If has to be displayed as incorrect.
+ text?: boolean; // If has to be displayed as simple text.
+ checked?: boolean; // If has to be displayed as a checked option.
+ unchecked?: boolean; // If has to be displayed as a unchecked option.
+ pass?: boolean; // If has to be displayed as passed.
+ fail?: boolean; // If has to be displayed as failed.
+};
+
+/**
+ * User attempts data with some calculated data.
+ */
+export type AddonModH5PActivityUserAttempts = Omit & {
+ attempts: AddonModH5PActivityAttempt[]; // The complete attempts list.
+ scored?: { // Attempts used to grade the activity.
+ title: string; // Scored attempts title.
+ grademethod: string; // Scored attempts title.
+ attempts: AddonModH5PActivityAttempt[]; // List of the grading attempts.
+ };
+};
+
+/**
+ * Attempt with some calculated data.
+ */
+export type AddonModH5PActivityAttempt = AddonModH5PActivityWSAttempt & {
+ durationReadable?: string; // Duration in a human readable format.
+ durationCompact?: string; // Duration in a "short" human readable format.
+};
+
+/**
+ * Attempt and results data with some calculated data.
+ */
+export type AddonModH5PActivityAttemptResults = AddonModH5PActivityAttempt & {
+ results?: AddonModH5PActivityWSResult[]; // The results of the attempt.
+};
+
+/**
+ * Options to pass to getDeployedFile function.
+ */
+export type AddonModH5PActivityGetDeployedFileOptions = {
+ displayOptions?: CoreH5PDisplayOptions; // Display options
+ ignoreCache?: boolean; // Whether to ignore cache. Will fail if offline or server down.
+ siteId?: string; // Site ID. If not defined, current site.
+};
+
+/**
+ * Options to pass to getAttemptResults function.
+ */
+export type AddonModH5PActivityGetAttemptResultsOptions = CoreCourseCommonModWSOptions & {
+ userId?: number; // User ID. If not defined, user of the site.
+};
+
+/**
+ * Options to pass to getAttempts function.
+ */
+export type AddonModH5PActivityGetAttemptsOptions = AddonModH5PActivityGetAttemptResultsOptions;
+
+/**
+ * Params of mod_h5pactivity_view_h5pactivity WS.
+ */
+export type AddonModH5pactivityViewH5pactivityWSParams = {
+ h5pactivityid: number; // H5P activity instance id.
+};
+
+declare module '@singletons/events' {
+
+ /**
+ * Augment CoreEventsData interface with events specific to this service.
+ *
+ * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
+ */
+ export interface CoreEventsData {
+ [AddonModH5PActivitySyncProvider.AUTO_SYNCED]: AddonModH5PActivityAutoSyncData;
+ }
+
+}
diff --git a/src/addons/mod/h5pactivity/services/handlers/index-link.ts b/src/addons/mod/h5pactivity/services/handlers/index-link.ts
new file mode 100644
index 000000000..e791a0b3a
--- /dev/null
+++ b/src/addons/mod/h5pactivity/services/handlers/index-link.ts
@@ -0,0 +1,33 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler';
+import { makeSingleton } from '@singletons';
+
+/**
+ * Handler to treat links to H5P activity index.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModH5PActivityIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler {
+
+ name = 'AddonModH5PActivityIndexLinkHandler';
+
+ constructor() {
+ super('AddonModH5PActivity', 'h5pactivity');
+ }
+
+}
+
+export const AddonModH5PActivityIndexLinkHandler = makeSingleton(AddonModH5PActivityIndexLinkHandlerService);
diff --git a/src/addons/mod/h5pactivity/services/handlers/module.ts b/src/addons/mod/h5pactivity/services/handlers/module.ts
new file mode 100644
index 000000000..1875bed1f
--- /dev/null
+++ b/src/addons/mod/h5pactivity/services/handlers/module.ts
@@ -0,0 +1,85 @@
+// (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 { CoreConstants } from '@/core/constants';
+import { Injectable, Type } from '@angular/core';
+import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
+import { CoreCourseModule } from '@features/course/services/course-helper';
+import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
+import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
+import { makeSingleton } from '@singletons';
+import { AddonModH5PActivityIndexComponent } from '../../components/index';
+import { AddonModH5PActivity } from '../h5pactivity';
+
+/**
+ * Handler to support H5P activities.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModH5PActivityModuleHandlerService implements CoreCourseModuleHandler {
+
+ static readonly PAGE_NAME = 'mod_h5pactivity';
+
+ name = 'AddonModH5PActivity';
+ modName = 'h5pactivity';
+
+ supportedFeatures = {
+ [CoreConstants.FEATURE_GROUPS]: true,
+ [CoreConstants.FEATURE_GROUPINGS]: true,
+ [CoreConstants.FEATURE_MOD_INTRO]: true,
+ [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true,
+ [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true,
+ [CoreConstants.FEATURE_MODEDIT_DEFAULT_COMPLETION]: true,
+ [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true,
+ [CoreConstants.FEATURE_GRADE_OUTCOMES]: true,
+ [CoreConstants.FEATURE_BACKUP_MOODLE2]: true,
+ };
+
+ /**
+ * @inheritdoc
+ */
+ isEnabled(): Promise {
+ return AddonModH5PActivity.isPluginEnabled();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData {
+
+ return {
+ icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined),
+ title: module.name,
+ class: 'addon-mod_h5pactivity-handler',
+ showDownloadButton: true,
+ action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) {
+ options = options || {};
+ options.params = options.params || {};
+ Object.assign(options.params, { module });
+ const routeParams = '/' + courseId + '/' + module.id;
+
+ CoreNavigator.navigateToSitePath(AddonModH5PActivityModuleHandlerService.PAGE_NAME + routeParams, options);
+ },
+ };
+ }
+
+ /**
+ * @inheritdoc
+ */
+ async getMainComponent(): Promise> {
+ return AddonModH5PActivityIndexComponent;
+ }
+
+}
+
+export const AddonModH5PActivityModuleHandler = makeSingleton(AddonModH5PActivityModuleHandlerService);
diff --git a/src/addons/mod/h5pactivity/services/handlers/prefetch.ts b/src/addons/mod/h5pactivity/services/handlers/prefetch.ts
new file mode 100644
index 000000000..03a41dead
--- /dev/null
+++ b/src/addons/mod/h5pactivity/services/handlers/prefetch.ts
@@ -0,0 +1,171 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+
+import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
+import { CoreCourseAnyModuleData } from '@features/course/services/course';
+import { CoreH5PHelper } from '@features/h5p/classes/helper';
+import { CoreH5P } from '@features/h5p/services/h5p';
+import { CoreUser } from '@features/user/services/user';
+import { CoreFilepool } from '@services/filepool';
+import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
+import { CoreWSExternalFile } from '@services/ws';
+import { makeSingleton } from '@singletons';
+import { AddonModH5PActivity, AddonModH5PActivityData, AddonModH5PActivityProvider } from '../h5pactivity';
+
+/**
+ * Handler to prefetch h5p activity.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
+
+ name = 'AddonModH5PActivity';
+ modName = 'h5pactivity';
+ component = AddonModH5PActivityProvider.COMPONENT;
+ updatesNames = /^configuration$|^.*files$|^tracks$|^usertracks$/;
+
+ /**
+ * @inheritdoc
+ */
+ async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise {
+
+ const h5pActivity = await AddonModH5PActivity.getH5PActivity(courseId, module.id);
+
+ const displayOptions = CoreH5PHelper.decodeDisplayOptions(h5pActivity.displayoptions);
+
+ const deployedFile = await AddonModH5PActivity.getDeployedFile(h5pActivity, {
+ displayOptions,
+ });
+
+ return [deployedFile].concat(this.getIntroFilesFromInstance(module, h5pActivity));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ async invalidateModule(): Promise {
+ // No need to invalidate anything.
+ }
+
+ /**
+ * @inheritdoc
+ */
+ async isDownloadable(): Promise {
+ return !!CoreSites.getCurrentSite()?.canDownloadFiles() && !CoreH5P.isOfflineDisabledInSite();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ isEnabled(): Promise {
+ return AddonModH5PActivity.isPluginEnabled();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise {
+ return this.prefetchPackage(module, courseId, this.prefetchActivity.bind(this, module, courseId));
+ }
+
+ /**
+ * Prefetch an H5P activity.
+ *
+ * @param module Module.
+ * @param courseId Course ID the module belongs to.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when done.
+ */
+ protected async prefetchActivity(
+ module: CoreCourseAnyModuleData,
+ courseId: number,
+ siteId?: string,
+ ): Promise {
+ siteId = siteId || CoreSites.getCurrentSiteId();
+
+ const h5pActivity = await AddonModH5PActivity.getH5PActivity(courseId, module.id, {
+ readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+ siteId,
+ });
+
+ const introFiles = this.getIntroFilesFromInstance(module, h5pActivity);
+
+ await Promise.all([
+ this.prefetchWSData(h5pActivity, siteId),
+ CoreFilepool.addFilesToQueue(siteId, introFiles, AddonModH5PActivityProvider.COMPONENT, module.id),
+ this.prefetchMainFile(module, h5pActivity, siteId),
+ ]);
+ }
+
+ /**
+ * Prefetch the deployed file of the activity.
+ *
+ * @param module Module.
+ * @param h5pActivity Activity instance.
+ * @param siteId Site ID.
+ * @return Promise resolved when done.
+ */
+ protected async prefetchMainFile(
+ module: CoreCourseAnyModuleData,
+ h5pActivity: AddonModH5PActivityData,
+ siteId: string,
+ ): Promise {
+
+ const displayOptions = CoreH5PHelper.decodeDisplayOptions(h5pActivity.displayoptions);
+
+ const deployedFile = await AddonModH5PActivity.getDeployedFile(h5pActivity, {
+ displayOptions: displayOptions,
+ ignoreCache: true,
+ siteId: siteId,
+ });
+
+ await CoreFilepool.addFilesToQueue(siteId, [deployedFile], AddonModH5PActivityProvider.COMPONENT, module.id);
+ }
+
+ /**
+ * Prefetch all the WebService data.
+ *
+ * @param h5pActivity Activity instance.
+ * @param siteId Site ID.
+ * @return Promise resolved when done.
+ */
+ protected async prefetchWSData(h5pActivity: AddonModH5PActivityData, siteId: string): Promise {
+
+ const accessInfo = await AddonModH5PActivity.getAccessInformation(h5pActivity.id, {
+ cmId: h5pActivity.coursemodule,
+ readingStrategy: CoreSitesReadingStrategy.PreferCache,
+ siteId,
+ });
+
+ if (!accessInfo.canreviewattempts) {
+ // Not a teacher, prefetch user attempts and the current user profile.
+ const site = await CoreSites.getSite(siteId);
+
+ const options = {
+ cmId: h5pActivity.coursemodule,
+ readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+ siteId: siteId,
+ };
+
+ await Promise.all([
+ AddonModH5PActivity.getAllAttemptsResults(h5pActivity.id, options),
+ CoreUser.prefetchProfiles([site.getUserId()], h5pActivity.course, siteId),
+ ]);
+ }
+ }
+
+}
+
+export const AddonModH5PActivityPrefetchHandler = makeSingleton(AddonModH5PActivityPrefetchHandlerService);
diff --git a/src/addons/mod/h5pactivity/services/handlers/report-link.ts b/src/addons/mod/h5pactivity/services/handlers/report-link.ts
new file mode 100644
index 000000000..ee6b8c9ab
--- /dev/null
+++ b/src/addons/mod/h5pactivity/services/handlers/report-link.ts
@@ -0,0 +1,136 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+
+import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
+import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
+import { CoreCourse } from '@features/course/services/course';
+import { CoreNavigator } from '@services/navigator';
+import { CoreSites } from '@services/sites';
+import { CoreDomUtils } from '@services/utils/dom';
+import { makeSingleton } from '@singletons';
+import { AddonModH5PActivity } from '../h5pactivity';
+import { AddonModH5PActivityModuleHandlerService } from './module';
+
+/**
+ * Handler to treat links to H5P activity report.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModH5PActivityReportLinkHandlerService extends CoreContentLinksHandlerBase {
+
+ name = 'AddonModH5PActivityReportLinkHandler';
+ featureName = 'CoreCourseModuleDelegate_AddonModH5PActivity';
+ pattern = /\/mod\/h5pactivity\/report\.php.*([&?]a=\d+)/;
+
+ /**
+ * @inheritdoc
+ */
+ getActions(
+ siteIds: string[],
+ url: string,
+ params: Record,
+ courseId?: number,
+ ): CoreContentLinksAction[] | Promise {
+ courseId = courseId || Number(params.courseid) || Number(params.cid);
+
+ return [{
+ action: async (siteId) => {
+ try {
+ const instanceId = Number(params.a);
+
+ if (!courseId) {
+ courseId = await this.getCourseId(instanceId, siteId);
+ }
+
+ const module = await CoreCourse.getModuleBasicInfoByInstance(instanceId, 'h5pactivity', siteId);
+
+ if (typeof params.attemptid != 'undefined') {
+ this.openAttemptResults(module.id, Number(params.attemptid), courseId, siteId);
+ } else {
+ const userId = params.userid ? Number(params.userid) : undefined;
+
+ this.openUserAttempts(module.id, courseId, siteId, userId);
+ }
+ } catch (error) {
+ CoreDomUtils.showErrorModalDefault(error, 'Error processing link.');
+ }
+ },
+ }];
+ }
+
+ /**
+ * Get course Id for an activity.
+ *
+ * @param id Activity ID.
+ * @param siteId Site ID.
+ * @return Promise resolved with course ID.
+ */
+ protected async getCourseId(id: number, siteId: string): Promise {
+ const modal = await CoreDomUtils.showModalLoading();
+
+ try {
+ const module = await CoreCourse.getModuleBasicInfoByInstance(id, 'h5pactivity', siteId);
+
+ return module.course;
+ } finally {
+ modal.dismiss();
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ isEnabled(): Promise {
+ return AddonModH5PActivity.isPluginEnabled();
+ }
+
+ /**
+ * Open attempt results.
+ *
+ * @param cmId Module ID.
+ * @param attemptId Attempt ID.
+ * @param courseId Course ID.
+ * @param siteId Site ID.
+ * @return Promise resolved when done.
+ */
+ protected openAttemptResults(cmId: number, attemptId: number, courseId: number, siteId: string): void {
+ const path = AddonModH5PActivityModuleHandlerService.PAGE_NAME + `/${courseId}/${cmId}/attemptresults/${attemptId}`;
+
+ CoreNavigator.navigateToSitePath(path, {
+ siteId,
+ });
+ }
+
+ /**
+ * Open user attempts.
+ *
+ * @param cmId Module ID.
+ * @param courseId Course ID.
+ * @param siteId Site ID.
+ * @param userId User ID. If not defined, current user in site.
+ * @return Promise resolved when done.
+ */
+ protected openUserAttempts(cmId: number, courseId: number, siteId: string, userId?: number): void {
+ userId = userId || CoreSites.getCurrentSiteUserId();
+ const path = AddonModH5PActivityModuleHandlerService.PAGE_NAME + `/${courseId}/${cmId}/userattempts/${userId}`;
+
+ CoreNavigator.navigateToSitePath(path, {
+ siteId,
+ });
+ }
+
+}
+
+export const AddonModH5PActivityReportLinkHandler = makeSingleton(AddonModH5PActivityReportLinkHandlerService);
diff --git a/src/addons/mod/h5pactivity/services/handlers/sync-cron.ts b/src/addons/mod/h5pactivity/services/handlers/sync-cron.ts
new file mode 100644
index 000000000..9742b4245
--- /dev/null
+++ b/src/addons/mod/h5pactivity/services/handlers/sync-cron.ts
@@ -0,0 +1,52 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+
+import { CoreCronHandler } from '@services/cron';
+import { makeSingleton } from '@singletons';
+import { AddonModH5PActivitySync } from '../h5pactivity-sync';
+
+/**
+ * Synchronization cron handler.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModH5PActivitySyncCronHandlerService implements CoreCronHandler {
+
+ name = 'AddonModH5PActivitySyncCronHandler';
+
+ /**
+ * Execute the process.
+ * Receives the ID of the site affected, undefined for all sites.
+ *
+ * @param siteId ID of the site affected, undefined for all sites.
+ * @param force Wether the execution is forced (manual sync).
+ * @return Promise resolved when done, rejected if failure.
+ */
+ execute(siteId?: string, force?: boolean): Promise {
+ return AddonModH5PActivitySync.syncAllActivities(siteId, force);
+ }
+
+ /**
+ * Get the time between consecutive executions.
+ *
+ * @return Time between consecutive executions (in ms).
+ */
+ getInterval(): number {
+ return AddonModH5PActivitySync.syncInterval;
+ }
+
+}
+
+export const AddonModH5PActivitySyncCronHandler = makeSingleton(AddonModH5PActivitySyncCronHandlerService);
diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts
index bf3163e78..95fdd0b31 100644
--- a/src/addons/mod/mod.module.ts
+++ b/src/addons/mod/mod.module.ts
@@ -26,6 +26,7 @@ import { AddonModQuizModule } from './quiz/quiz.module';
import { AddonModResourceModule } from './resource/resource.module';
import { AddonModUrlModule } from './url/url.module';
import { AddonModLtiModule } from './lti/lti.module';
+import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module';
@NgModule({
declarations: [],
@@ -42,6 +43,7 @@ import { AddonModLtiModule } from './lti/lti.module';
AddonModFolderModule,
AddonModImscpModule,
AddonModLtiModule,
+ AddonModH5PActivityModule,
],
providers: [],
exports: [],
diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts
index 8a10e06a7..e07d3e619 100644
--- a/src/core/features/compile/services/compile.ts
+++ b/src/core/features/compile/services/compile.ts
@@ -129,7 +129,7 @@ import { ADDON_MOD_BOOK_SERVICES } from '@addons/mod/book/book.module';
import { ADDON_MOD_FOLDER_SERVICES } from '@addons/mod/folder/folder.module';
import { ADDON_MOD_FORUM_SERVICES } from '@addons/mod/forum/forum.module';
// @todo import { ADDON_MOD_GLOSSARY_SERVICES } from '@addons/mod/glossary/glossary.module';
-// @todo import { ADDON_MOD_H5P_ACTIVITY_SERVICES } from '@addons/mod/h5pactivity/h5pactivity.module';
+import { ADDON_MOD_H5P_ACTIVITY_SERVICES } from '@addons/mod/h5pactivity/h5pactivity.module';
import { ADDON_MOD_IMSCP_SERVICES } from '@addons/mod/imscp/imscp.module';
import { ADDON_MOD_LESSON_SERVICES } from '@addons/mod/lesson/lesson.module';
import { ADDON_MOD_LTI_SERVICES } from '@addons/mod/lti/lti.module';
@@ -294,7 +294,7 @@ export class CoreCompileProvider {
...ADDON_MOD_FOLDER_SERVICES,
...ADDON_MOD_FORUM_SERVICES,
// @todo ...ADDON_MOD_GLOSSARY_SERVICES,
- // @todo ...ADDON_MOD_H5P_ACTIVITY_SERVICES,
+ ...ADDON_MOD_H5P_ACTIVITY_SERVICES,
...ADDON_MOD_IMSCP_SERVICES,
...ADDON_MOD_LESSON_SERVICES,
...ADDON_MOD_LTI_SERVICES,
diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts
index 239ae758b..e39121412 100644
--- a/src/core/features/course/classes/main-resource-component.ts
+++ b/src/core/features/course/classes/main-resource-component.ts
@@ -226,7 +226,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
async gotoBlog(): Promise {
const params: Params = { cmId: this.module.id };
- CoreNavigator.navigateToSitePath(AddonBlogMainMenuHandlerService.PAGE_NAME, { params });
+ await CoreNavigator.navigateToSitePath(AddonBlogMainMenuHandlerService.PAGE_NAME, { params });
}
/**
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 a05f4cdcc..9cbc3878b 100644
--- a/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts
+++ b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts
@@ -68,7 +68,6 @@ export class CoreH5PIframeComponent implements OnChanges, OnDestroy {
this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.isOfflineDisabledInSite();
// Send resize events when the page holding this component is re-entered.
- // @todo: Check that this works as expected.
this.currentPageRoute = router.url;
this.subscription = router.events
.pipe(filter(event => event instanceof NavigationEnd))
diff --git a/src/core/services/file.ts b/src/core/services/file.ts
index 94b608103..7e903a46d 100644
--- a/src/core/services/file.ts
+++ b/src/core/services/file.ts
@@ -1215,7 +1215,12 @@ export class CoreFileProvider {
* @return Path.
*/
getWWWPath(): string {
- const position = window.location.href.indexOf('index.html');
+ // Use current URL, removing the path.
+ if (!window.location.pathname || window.location.pathname == '/') {
+ return window.location.href;
+ }
+
+ const position = window.location.href.indexOf(window.location.pathname);
if (position != -1) {
return window.location.href.substr(0, position);