Merge pull request #3599 from dpalou/MOBILE-4269

Mobile 4269
main
Pau Ferrer Ocaña 2023-04-12 13:27:56 +02:00 committed by GitHub
commit 10f0993c05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1618 additions and 109 deletions

View File

@ -2148,6 +2148,7 @@
"core.minutes": "moodle",
"core.misc": "admin",
"core.mod_assign": "assign/pluginname",
"core.mod_bigbluebuttonbn": "bigbluebuttonbn/pluginname",
"core.mod_book": "book/pluginname",
"core.mod_chat": "chat/pluginname",
"core.mod_choice": "choice/pluginname",
@ -2520,6 +2521,7 @@
"core.viewprofile": "moodle",
"core.wanttochangesite": "local_moodlemobileapp",
"core.warningofflinedatadeleted": "local_moodlemobileapp",
"core.warningofflinedatadeletedreason": "local_moodlemobileapp",
"core.warnopeninbrowser": "local_moodlemobileapp",
"core.week": "moodle",
"core.weeks": "moodle",

View File

@ -18,6 +18,14 @@
* Helper functions for converting a Moodle WS structure to a TS type.
*/
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_value;
use core_external\external_warnings;
use core_external\external_files;
use core_external\external_single_structure;
use core_external\external_multiple_structure;
/**
* Get the structure of a WS params or returns.
*/

View File

@ -313,7 +313,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
const result = await AddonCalendarSync.syncEvents();
if (result.warnings && result.warnings.length) {
CoreDomUtils.showErrorModal(result.warnings[0]);
CoreDomUtils.showAlert(undefined, result.warnings[0]);
}
if (result.updated) {

View File

@ -328,7 +328,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
try {
const result = await AddonCalendarSync.syncEvents();
if (result.warnings && result.warnings.length) {
CoreDomUtils.showErrorModal(result.warnings[0]);
CoreDomUtils.showAlert(undefined, result.warnings[0]);
}
if (result.deleted && result.deleted.indexOf(this.eventId) != -1) {

View File

@ -200,7 +200,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
try {
const result = await AddonCalendarSync.syncEvents();
if (result.warnings && result.warnings.length) {
CoreDomUtils.showErrorModal(result.warnings[0]);
CoreDomUtils.showAlert(undefined, result.warnings[0]);
}
if (result.updated) {

View File

@ -131,7 +131,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
// Show first warning if any.
if (data.warnings && data.warnings[0]) {
CoreDomUtils.showErrorModal(data.warnings[0]);
CoreDomUtils.showAlert(undefined, data.warnings[0]);
}
}
}, this.siteId);
@ -252,7 +252,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
try {
const syncResult = await AddonMessagesSync.syncDiscussion(this.conversationId, this.userId);
if (syncResult.warnings && syncResult.warnings[0]) {
CoreDomUtils.showErrorModal(syncResult.warnings[0]);
CoreDomUtils.showAlert(undefined, syncResult.warnings[0]);
}
} catch {
// Ignore errors;

View File

@ -372,7 +372,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo
if (syncEventData.warnings && syncEventData.warnings.length) {
// Show warnings.
CoreDomUtils.showErrorModal(syncEventData.warnings[0]);
CoreDomUtils.showAlert(undefined, syncEventData.warnings[0]);
}
return true;

View File

@ -611,7 +611,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
.syncDiscussionReplies(this.discussionId)
.then((result) => {
if (result.warnings && result.warnings.length) {
CoreDomUtils.showErrorModal(result.warnings[0]);
CoreDomUtils.showAlert(undefined, result.warnings[0]);
}
if (result && result.updated && this.forumId) {
@ -632,7 +632,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
.syncRatings(this.cmId, this.discussionId)
.then((result) => {
if (result.warnings && result.warnings.length) {
CoreDomUtils.showErrorModal(result.warnings[0]);
CoreDomUtils.showAlert(undefined, result.warnings[0]);
}
return;

View File

@ -64,7 +64,8 @@
</ion-list>
<core-h5p-iframe *ngIf="playing" [fileUrl]="fileUrl" [displayOptions]="displayOptions" [onlinePlayerUrl]="onlinePlayerUrl"
[trackComponent]="trackComponent" [contextId]="h5pActivity?.context" [enableInAppFullscreen]="true">
[trackComponent]="trackComponent" [contextId]="h5pActivity?.context" [enableInAppFullscreen]="true" [saveFreq]="saveFreq"
[state]="contentState">
</core-h5p-iframe>
</core-loading>

View File

@ -27,7 +27,7 @@ import { CoreXAPI } from '@features/xapi/services/xapi';
import { CoreNetwork } from '@services/network';
import { CoreFilepool } from '@services/filepool';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreWSFile } from '@services/ws';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
@ -36,7 +36,10 @@ import {
AddonModH5PActivityAccessInfo,
AddonModH5PActivityData,
AddonModH5PActivityProvider,
AddonModH5PActivityXAPIData,
AddonModH5PActivityXAPIPostStateData,
AddonModH5PActivityXAPIStateData,
AddonModH5PActivityXAPIStatementsData,
MOD_H5PACTIVITY_STATE_ID,
} from '../../services/h5pactivity';
import {
AddonModH5PActivitySync,
@ -45,6 +48,8 @@ import {
} from '../../services/h5pactivity-sync';
import { CoreFileHelper } from '@services/file-helper';
import { AddonModH5PActivityModuleHandlerService } from '../../services/handlers/module';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
/**
* Component that displays an H5P activity entry page.
@ -80,6 +85,9 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
hasOffline = false;
isOpeningPage = false;
canViewAllAttempts = false;
saveStateEnabled = false;
saveFreq?: number;
contentState?: string;
protected fetchContentDefaultError = 'addon.mod_h5pactivity.errorgetactivity';
protected syncEventName = AddonModH5PActivitySyncProvider.AUTO_SYNCED;
@ -108,13 +116,17 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
async ngOnInit(): Promise<void> {
super.ngOnInit();
this.loadContent();
this.loadContent(false, true);
}
/**
* @inheritdoc
*/
protected async fetchContent(refresh?: boolean, sync = false, showErrors = false): Promise<void> {
// Always show loading and stop playing, the package needs to be reloaded with the latest data.
this.showLoading = true;
this.playing = false;
this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.module.id, {
siteId: this.siteId,
});
@ -133,6 +145,8 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
this.fetchDeployedFileData(),
]);
await this.loadContentState(); // Loading the state requires the access info.
this.trackComponent = this.accessInfo?.cansubmit ? AddonModH5PActivityProvider.TRACK_COMPONENT : '';
this.canViewAllAttempts = !!this.h5pActivity.enabletracking && !!this.accessInfo?.canreviewattempts &&
AddonModH5PActivity.canGetUsersAttemptsInSite();
@ -168,7 +182,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
return;
}
this.hasOffline = await CoreXAPIOffline.contextHasStatements(this.h5pActivity.context, this.siteId);
this.hasOffline = await CoreXAPIOffline.contextHasData(this.h5pActivity.context, this.siteId);
}
/**
@ -217,6 +231,40 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
await this.calculateFileState();
}
/**
* Load the content's state (if enabled and there's any).
*/
protected async loadContentState(): Promise<void> {
if (!this.h5pActivity || !this.accessInfo || !AddonModH5PActivity.isSaveStateEnabled(this.h5pActivity, this.accessInfo)) {
this.saveStateEnabled = false;
return;
}
this.saveStateEnabled = true;
this.saveFreq = this.h5pActivity.savestatefreq;
const contentState = await CoreXAPI.getState(
AddonModH5PActivityProvider.TRACK_COMPONENT,
this.h5pActivity.context,
MOD_H5PACTIVITY_STATE_ID,
{
appComponent: AddonModH5PActivityProvider.COMPONENT,
appComponentId: this.h5pActivity.coursemodule,
readingStrategy: CoreSitesReadingStrategy.PREFER_NETWORK,
},
);
if (contentState === null) {
return;
}
const contentStateObj = CoreTextUtils.parseJSON<{h5p: string}>(contentState, { h5p: '{}' });
// The H5P state doesn't always use JSON, so an h5p property was added to jsonize it.
this.contentState = contentStateObj.h5p ?? '{}';
}
/**
* Calculate the state of the deployed file.
*
@ -339,6 +387,9 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
this.downloading = true;
this.progressMessage = 'core.downloading';
// Delete offline states when downloading the package because it means the package has changed or user deleted it.
this.deleteOfflineStates();
try {
await CoreFilepool.downloadUrl(
this.site.getId(),
@ -434,7 +485,78 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
*/
protected async onIframeMessage(event: MessageEvent): Promise<void> {
const data = event.data;
if (!data || !this.h5pActivity || !CoreXAPI.canPostStatementsInSite(this.site) || !this.isCurrentXAPIPost(data)) {
if (!data || !this.h5pActivity) {
return;
}
if (CoreXAPI.canPostStatementsInSite(this.site) && this.isCurrentXAPIPostStatement(data)) {
this.postStatements(data);
} else if (this.saveStateEnabled && this.isCurrentXAPIState(data, 'xapi_post_state') && this.isXAPIPostState(data)) {
this.postState(data);
} else if (this.saveStateEnabled && this.isCurrentXAPIState(data, 'xapi_delete_state')) {
this.deleteState(data);
}
}
/**
* Check if an event is an H5P event meant for this app.
*
* @param data Event data.
* @returns Whether it's an H5P event meant for this app.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected isH5PEventForApp(data: any): boolean {
return data.environment === 'moodleapp' && data.context === 'h5p';
}
/**
* Check if an activity ID (an IRI) belongs to the current activity.
*
* @param activityId Activity ID (IRI).
* @returns Whether it belongs to the current activity.
*/
protected activityIdIsCurrentActivity(activityId?: string): boolean {
if (!activityId || !this.h5pActivity) {
return false;
}
if (!this.site.containsUrl(activityId)) {
// The event belongs to another site, weird scenario. Maybe some JS running in background.
return false;
}
const match = activityId.match(/xapi\/activity\/(\d+)/);
return !!match && Number(match[1]) === this.h5pActivity.context;
}
/**
* Check if an event is an XAPI post statement of the current activity.
*
* @param data Event data.
* @returns Whether it's an XAPI post statement of the current activity.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected isCurrentXAPIPostStatement(data: any): data is AddonModH5PActivityXAPIStatementsData {
if (!this.h5pActivity) {
return false;
}
if (!this.isH5PEventForApp(data) || data.action !== 'xapi_post_statement' || !data.statements) {
return false;
}
// Check the event belongs to this activity.
return this.activityIdIsCurrentActivity(data.statements[0] && data.statements[0].object && data.statements[0].object.id);
}
/**
* Post statements.
*
* @param data Event data.
*/
protected async postStatements(data: AddonModH5PActivityXAPIStatementsData): Promise<void> {
if (!this.h5pActivity) {
return;
}
@ -454,6 +576,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
);
this.hasOffline = !sent;
this.deleteOfflineStates(); // Posting statements means attempt has finished, delete any offline state.
if (sent) {
try {
@ -476,35 +599,97 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
}
/**
* Check if an event is an XAPI post statement of the current activity.
* Check if an event is an XAPI state event of the current activity.
*
* @param data Event data.
* @returns Whether it's an XAPI post statement of the current activity.
* @param action Action to check.
* @returns Whether it's an XAPI state event of the current activity.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected isCurrentXAPIPost(data: any): data is AddonModH5PActivityXAPIData {
protected isCurrentXAPIState(data: any, action: string): data is AddonModH5PActivityXAPIStateData {
if (!this.h5pActivity) {
return false;
}
if (data.environment != 'moodleapp' || data.context != 'h5p' || data.action != 'xapi_post_statement' || !data.statements) {
if (!this.isH5PEventForApp(data) || data.action !== action) {
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;
return this.activityIdIsCurrentActivity(data.activityId);
}
/**
* Check if an xAPI state event data is a post state event.
*
* @param data Event data.
* @returns Whether it's an XAPI post state.
*/
protected isXAPIPostState(data: AddonModH5PActivityXAPIStateData): data is AddonModH5PActivityXAPIPostStateData {
return 'stateData' in data;
}
/**
* Post state.
*
* @param data Event data.
*/
protected async postState(data: AddonModH5PActivityXAPIPostStateData): Promise<void> {
try {
const options = {
offline: this.hasOffline,
courseId: this.courseId,
extra: this.h5pActivity?.name,
siteId: this.site.getId(),
};
const sent = await CoreXAPI.postState(
data.component,
data.activityId,
data.agent,
data.stateId,
data.stateData,
options,
);
this.hasOffline = !sent;
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error sending tracking data.');
}
}
/**
* Delete state.
*
* @param data Event data.
*/
protected async deleteState(data: AddonModH5PActivityXAPIStateData): Promise<void> {
try {
await CoreXAPI.deleteState(
data.component,
data.activityId,
data.agent,
data.stateId,
{
siteId: this.site.getId(),
},
);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error sending tracking data.');
}
}
/**
* Delete offline states for current activity.
*/
protected async deleteOfflineStates(): Promise<void> {
if (!this.h5pActivity) {
return;
}
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;
await CoreUtils.ignoreErrors(CoreXAPIOffline.deleteStates(AddonModH5PActivityProvider.TRACK_COMPONENT, {
itemId: this.h5pActivity.context,
}));
}
/**
@ -535,7 +720,11 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
super.ngOnDestroy();
this.observer?.off();
window.removeEventListener('message', this.messageListenerFunction);
// Wait a bit to make sure all messages have been received.
setTimeout(() => {
window.removeEventListener('message', this.messageListenerFunction);
}, 2000);
}
}

View File

@ -39,7 +39,7 @@ export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPa
* @inheritdoc
*/
async canLeave(): Promise<boolean> {
if (!this.activityComponent || !this.activityComponent.playing || this.activityComponent.isOpeningPage) {
if (!this.activityComponent?.playing || this.activityComponent.isOpeningPage || this.activityComponent.saveStateEnabled) {
return true;
}

View File

@ -19,13 +19,23 @@ import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/act
import { CoreSyncResult } from '@services/sync';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreXAPIOffline } from '@features/xapi/services/offline';
import { CoreXAPI } from '@features/xapi/services/xapi';
import { CoreXAPI, XAPI_STATE_DELETED } from '@features/xapi/services/xapi';
import { CoreNetwork } from '@services/network';
import { CoreSites } from '@services/sites';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { makeSingleton, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { AddonModH5PActivity, AddonModH5PActivityProvider } from './h5pactivity';
import {
AddonModH5PActivity,
AddonModH5PActivityAttempt,
AddonModH5PActivityData,
AddonModH5PActivityProvider,
} from './h5pactivity';
import { CoreXAPIStateDBRecord, CoreXAPIStatementDBRecord } from '@features/xapi/services/database/xapi';
import { CoreTextUtils } from '@services/utils/text';
import { CoreXAPIIRI } from '@features/xapi/classes/iri';
import { CoreXAPIItemAgent } from '@features/xapi/classes/item-agent';
import { CoreWSError } from '@classes/errors/wserror';
/**
* Service to sync H5P activities.
@ -60,17 +70,23 @@ export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseP
* @returns Promise resolved if sync is successful, rejected if sync fails.
*/
protected async syncAllActivitiesFunc(force: boolean, siteId?: string): Promise<void> {
const entries = await CoreXAPIOffline.getAllStatements(siteId);
const [statements, states] = await Promise.all([
CoreXAPIOffline.getAllStatements(siteId),
CoreXAPIOffline.getAllStates(siteId),
]);
// Sync all responses.
const promises = entries.map(async (response) => {
const result = await (force ? this.syncActivity(response.contextid, siteId) :
this.syncActivityIfNeeded(response.contextid, siteId));
const entries = (<(CoreXAPIStatementDBRecord|CoreXAPIStateDBRecord)[]> statements).concat(states);
const contextIds = CoreUtils.uniqueArray(entries.map(entry => 'contextid' in entry ? entry.contextid : entry.itemid));
// Sync all activities.
const promises = contextIds.map(async (contextId) => {
const result = await (force ? this.syncActivity(contextId, siteId) :
this.syncActivityIfNeeded(contextId, siteId));
if (result?.updated) {
// Sync successful, send event.
CoreEvents.trigger(AddonModH5PActivitySyncProvider.AUTO_SYNCED, {
contextId: response.contextid,
contextId,
warnings: result.warnings,
}, siteId);
}
@ -129,34 +145,102 @@ export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseP
this.logger.debug(`Try to sync H5P activity with context ID '${contextId}'`);
let h5pActivity: AddonModH5PActivityData | null = null;
const result: AddonModH5PActivitySyncResult = {
warnings: [],
updated: false,
};
// Get all the statements stored for the activity.
const entries = await CoreXAPIOffline.getContextStatements(contextId, siteId);
const [statements, states] = await Promise.all([
CoreXAPIOffline.getContextStatements(contextId, siteId),
CoreXAPIOffline.getItemStates(contextId, siteId),
]);
if (!entries || !entries.length) {
// Nothing to sync.
const deleteOfflineData = async (): Promise<void> => {
await Promise.all([
statements.length ? CoreXAPIOffline.deleteStatementsForContext(contextId, siteId) : undefined,
states.length ? CoreXAPIOffline.deleteStates(AddonModH5PActivityProvider.TRACK_COMPONENT, {
itemId: contextId,
siteId,
}) : undefined,
]);
result.updated = true;
};
const finishSync = async (): Promise<AddonModH5PActivitySyncResult> => {
await this.setSyncTime(contextId, siteId);
return result;
};
if (!statements.length && !states.length) {
// Nothing to sync.
return finishSync();
}
// Get the activity instance.
const courseId = entries[0].courseid!;
const courseId = (statements.find(statement => !!statement.courseid) ?? states.find(state => !!state.courseid))?.courseid;
if (!courseId) {
// Data not valid (shouldn't happen), delete it.
await deleteOfflineData();
const h5pActivity = await AddonModH5PActivity.getH5PActivityByContextId(courseId, contextId, { siteId });
return finishSync();
}
try {
h5pActivity = await AddonModH5PActivity.getH5PActivityByContextId(courseId, contextId, { siteId });
} catch (error) {
if (
CoreUtils.isWebServiceError(error) ||
CoreTextUtils.getErrorMessageFromError(error) === Translate.instant('core.course.modulenotfound')
) {
// Activity no longer accessible. Delete the data and finish the sync.
await deleteOfflineData();
return finishSync();
}
throw error;
}
// Sync offline logs.
await CoreUtils.ignoreErrors(
CoreCourseLogHelper.syncActivity(AddonModH5PActivityProvider.COMPONENT, h5pActivity.id, siteId),
);
const results = await Promise.all([
this.syncStatements(h5pActivity.id, statements, siteId),
this.syncStates(h5pActivity, states, siteId),
]);
result.updated = results[0].updated || results[1].updated;
result.warnings = results[0].warnings.concat(results[1].warnings);
return finishSync();
}
/**
* Sync statements.
*
* @param id H5P activity ID.
* @param statements Statements to sync.
* @param siteId Site ID.
* @returns Promise resolved with the sync result.
*/
protected async syncStatements(
id: number,
statements: CoreXAPIStatementDBRecord[],
siteId: string,
): Promise<AddonModH5PActivitySyncResult> {
const result: AddonModH5PActivitySyncResult = {
warnings: [],
updated: false,
};
// Send the statements in order.
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
for (let i = 0; i < statements.length; i++) {
const entry = statements[i];
try {
await CoreXAPI.postStatementsOnline(entry.component, entry.statements, siteId);
@ -174,19 +258,126 @@ export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseP
await CoreXAPIOffline.deleteStatements(entry.id, siteId);
// Responses deleted, add a warning.
// Statements deleted, add a warning.
this.addOfflineDataDeletedWarning(result.warnings, entry.extra || '', error);
}
}
if (result.updated) {
// Data has been sent to server, invalidate attempts.
await CoreUtils.ignoreErrors(AddonModH5PActivity.invalidateUserAttempts(h5pActivity.id, undefined, siteId));
await CoreUtils.ignoreErrors(AddonModH5PActivity.invalidateUserAttempts(id, undefined, siteId));
}
// Sync finished, set sync time.
await this.setSyncTime(contextId, siteId);
return result;
}
/**
* Sync states.
*
* @param h5pActivity H5P activity instance.
* @param states States to sync.
* @param siteId Site ID.
* @returns Promise resolved with the sync result.
*/
protected async syncStates(
h5pActivity: AddonModH5PActivityData,
states: CoreXAPIStateDBRecord[],
siteId: string,
): Promise<AddonModH5PActivitySyncResult> {
const result: AddonModH5PActivitySyncResult = {
warnings: [],
updated: false,
};
if (!states.length) {
return result;
}
const [site, activityIRI] = await Promise.all([
CoreSites.getSite(siteId),
CoreXAPIIRI.generate(h5pActivity.context, 'activity', siteId),
]);
const agent = JSON.stringify(CoreXAPIItemAgent.createFromSite(site).getData());
let lastAttempt: AddonModH5PActivityAttempt | undefined;
try {
const attemptsData = await AddonModH5PActivity.getUserAttempts(h5pActivity.id, {
cmId: h5pActivity.context,
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
});
lastAttempt = attemptsData.attempts.pop();
} catch (error) {
// Error getting attempts. If the WS has thrown an exception it means the user cannot retrieve the attempts for
// some reason (it shouldn't happen), continue synchronizing in that case.
if (!CoreUtils.isWebServiceError(error)) {
throw error;
}
}
await Promise.all(states.map(async (state) => {
try {
if (lastAttempt && state.timecreated <= lastAttempt.timecreated) {
// State was created before the last attempt. It means the user finished an attempt in another device.
throw new CoreWSError({
message: Translate.instant('core.warningofflinedatadeletedreason'),
errorcode: 'offlinedataoutdated',
});
}
// Check if there is a newer state in LMS.
const onlineStates = await CoreXAPI.getStatesSince(state.component, h5pActivity.context, {
registration: state.registration,
since: state.timecreated,
siteId,
});
if (onlineStates.length) {
// There is newer data in the server, discard the offline data.
throw new CoreWSError({
message: Translate.instant('core.warningofflinedatadeletedreason'),
errorcode: 'offlinedataoutdated',
});
}
if (state.statedata === XAPI_STATE_DELETED) {
await CoreXAPI.deleteStateOnline(state.component, activityIRI, agent, state.stateid, {
registration: state.registration,
siteId,
});
} else if (state.statedata) {
await CoreXAPI.postStateOnline(state.component, activityIRI, agent, state.stateid, state.statedata, {
registration: state.registration,
siteId,
});
}
result.updated = true;
await CoreXAPIOffline.deleteStates(state.component, {
itemId: h5pActivity.context,
stateId: state.stateid,
registration: state.registration,
siteId,
});
} catch (error) {
if (!CoreUtils.isWebServiceError(error)) {
throw error;
}
// The WebService has thrown an error, this means the state cannot be submitted. Delete it.
result.updated = true;
await CoreXAPIOffline.deleteStates(state.component, {
itemId: h5pActivity.context,
stateId: state.stateid,
registration: state.registration,
siteId,
});
// State deleted, add a warning.
this.addOfflineDataDeletedWarning(result.warnings, state.extra || '', error);
}
}));
return result;
}

View File

@ -28,6 +28,8 @@ import { CoreError } from '@classes/errors/error';
import { AddonModH5PActivityAutoSyncData, AddonModH5PActivitySyncProvider } from './h5pactivity-sync';
import { CoreTime } from '@singletons/time';
export const MOD_H5PACTIVITY_STATE_ID = 'state';
const ROOT_CACHE_KEY = 'mmaModH5PActivity:';
/**
@ -528,7 +530,10 @@ export class AddonModH5PActivityProvider {
const currentActivity = response.h5pactivities.find((h5pActivity) => h5pActivity[key] == value);
if (currentActivity) {
return currentActivity;
return {
...currentActivity,
...response.h5pglobalsettings,
};
}
throw new CoreError(Translate.instant('core.course.modulenotfound'));
@ -756,6 +761,17 @@ export class AddonModH5PActivityProvider {
return site.wsAvailable('mod_h5pactivity_get_h5pactivities_by_courses');
}
/**
* Check if save state is enabled for a certain activity.
*
* @param h5pActivity Activity.
* @param accessInfo Access info.
* @returns Whether save state is enabled.
*/
isSaveStateEnabled(h5pActivity: AddonModH5PActivityData, accessInfo?: AddonModH5PActivityAccessInfo): boolean {
return !!(h5pActivity.enabletracking && h5pActivity.enablesavestate && (!accessInfo || accessInfo.cansubmit));
}
/**
* Report an H5P activity as being viewed.
*
@ -822,7 +838,7 @@ export const AddonModH5PActivity = makeSingleton(AddonModH5PActivityProvider);
/**
* Basic data for an H5P activity, exported by Moodle class h5pactivity_summary_exporter.
*/
export type AddonModH5PActivityData = {
export type AddonModH5PActivityWSData = {
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.
@ -849,6 +865,19 @@ export type AddonModH5PActivityData = {
};
};
/**
* Basic data for an H5P activity, with some calculated data.
*/
export type AddonModH5PActivityData = AddonModH5PActivityWSData & Partial<AddonModH5pactivityGlobalSettings>;
/**
* Global settings for H5P activities.
*/
export type AddonModH5pactivityGlobalSettings = {
enablesavestate: boolean; // Whether saving state is enabled.
savestatefreq?: number; // How often (in seconds) the state is saved.
};
/**
* Params of mod_h5pactivity_get_h5pactivities_by_courses WS.
*/
@ -860,7 +889,8 @@ export type AddonModH5pactivityGetByCoursesWSParams = {
* Data returned by mod_h5pactivity_get_h5pactivities_by_courses WS.
*/
export type AddonModH5pactivityGetByCoursesWSResponse = {
h5pactivities: AddonModH5PActivityData[];
h5pactivities: AddonModH5PActivityWSData[];
h5pglobalsettings?: AddonModH5pactivityGlobalSettings;
warnings?: CoreWSExternalWarning[];
};
@ -1136,14 +1166,36 @@ declare module '@singletons/events' {
/**
* Data to be sent using xAPI.
*/
export type AddonModH5PActivityXAPIData = {
export type AddonModH5PActivityXAPIBasicData = {
action: string;
component: string;
context: string;
environment: string;
};
/**
* Statements data to be sent using xAPI.
*/
export type AddonModH5PActivityXAPIStatementsData = AddonModH5PActivityXAPIBasicData & {
statements: AddonModH5PActivityStatement[];
};
/**
* States data to be sent using xAPI.
*/
export type AddonModH5PActivityXAPIStateData = AddonModH5PActivityXAPIBasicData & {
activityId: string;
agent: Record<string, unknown>;
stateId: string;
};
/**
* Post state data to be sent using xAPI.
*/
export type AddonModH5PActivityXAPIPostStateData = AddonModH5PActivityXAPIStateData & {
stateData: string;
};
/**
* xAPI statement.
*/
@ -1171,4 +1223,5 @@ export type AddonModH5PActivityStatement = {
id: string;
display: Record<string, string>;
};
timestamp?: string;
};

View File

@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreConstants } from '@/core/constants';
import { Injectable } from '@angular/core';
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
@ -19,11 +20,21 @@ 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 { CoreXAPIOffline } from '@features/xapi/services/offline';
import { CoreXAPI } from '@features/xapi/services/xapi';
import { CoreFileHelper } from '@services/file-helper';
import { CoreFilepool } from '@services/filepool';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSFile } from '@services/ws';
import { makeSingleton } from '@singletons';
import { AddonModH5PActivity, AddonModH5PActivityData, AddonModH5PActivityProvider } from '../h5pactivity';
import {
AddonModH5PActivity,
AddonModH5PActivityAccessInfo,
AddonModH5PActivityData,
AddonModH5PActivityProvider,
MOD_H5PACTIVITY_STATE_ID,
} from '../h5pactivity';
/**
* Handler to prefetch h5p activity.
@ -129,6 +140,18 @@ export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivit
siteId: siteId,
});
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));
if (fileState !== CoreConstants.DOWNLOADED) {
await CoreUtils.ignoreErrors(CoreXAPIOffline.deleteStates(AddonModH5PActivityProvider.TRACK_COMPONENT, {
itemId: h5pActivity.context,
siteId,
}));
}
}
await CoreFilepool.addFilesToQueue(siteId, [deployedFile], AddonModH5PActivityProvider.COMPONENT, module.id);
}
@ -140,13 +163,31 @@ export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivit
* @returns Promise resolved when done.
*/
protected async prefetchWSData(h5pActivity: AddonModH5PActivityData, siteId: string): Promise<void> {
const accessInfo = await AddonModH5PActivity.getAccessInformation(h5pActivity.id, {
cmId: h5pActivity.coursemodule,
readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE,
siteId,
});
await Promise.all([
this.prefetchAttempts(h5pActivity, accessInfo, siteId),
this.prefetchState(h5pActivity, accessInfo, siteId),
]);
}
/**
* Prefetch attempts.
*
* @param h5pActivity Activity instance.
* @param accessInfo Access info.
* @param siteId Site ID.
* @returns Promise resolved when done.
*/
protected async prefetchAttempts(
h5pActivity: AddonModH5PActivityData,
accessInfo: AddonModH5PActivityAccessInfo,
siteId: string,
): Promise<void> {
const options = {
cmId: h5pActivity.coursemodule,
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
@ -179,6 +220,36 @@ export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivit
}
}
/**
* Prefetch state.
*
* @param h5pActivity Activity instance.
* @param accessInfo Access info.
* @param siteId Site ID.
* @returns Promise resolved when done.
*/
protected async prefetchState(
h5pActivity: AddonModH5PActivityData,
accessInfo: AddonModH5PActivityAccessInfo,
siteId: string,
): Promise<void> {
if (!AddonModH5PActivity.isSaveStateEnabled(h5pActivity, accessInfo)) {
return;
}
await CoreXAPI.getStateFromServer(
AddonModH5PActivityProvider.TRACK_COMPONENT,
h5pActivity.context,
MOD_H5PACTIVITY_STATE_ID,
{
appComponent: AddonModH5PActivityProvider.COMPONENT,
appComponentId: h5pActivity.coursemodule,
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
siteId,
},
);
}
}
export const AddonModH5PActivityPrefetchHandler = makeSingleton(AddonModH5PActivityPrefetchHandlerService);

View File

@ -402,7 +402,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave {
}
// Retake hasn't changed, show the warning and finish the retake in offline.
CoreDomUtils.showErrorModal(result.warnings[0]);
CoreDomUtils.showAlert(undefined, result.warnings[0]);
}
this.offline = false;

View File

@ -809,7 +809,7 @@ export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComp
if (this.isCurrentView && syncEventData.warnings && syncEventData.warnings.length) {
// Show warnings.
CoreDomUtils.showErrorModal(syncEventData.warnings[0]);
CoreDomUtils.showAlert(undefined, syncEventData.warnings[0]);
}
// Check if current page was created or discarded.

View File

@ -290,7 +290,7 @@ export class AddonNotesListPage implements OnInit, OnDestroy {
const message = CoreTextUtils.buildMessage(warnings);
if (message) {
CoreDomUtils.showErrorModal(message);
CoreDomUtils.showAlert(undefined, message);
}
}

View File

@ -28,6 +28,7 @@ import {
CoreWSExternalWarning,
CoreWSUploadFileResult,
CoreWSPreSetsSplitRequest,
CoreWSTypeExpected,
} from '@services/ws';
import { CoreDomUtils, ToastDuration } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
@ -2571,7 +2572,7 @@ export type CoreSiteWSPreSets = {
/**
* Defaults to 'object'. Use it when you expect a type that's not an object|array.
*/
typeExpected?: string;
typeExpected?: CoreWSTypeExpected;
/**
* Wehther a pending request in the queue matching the same function and arguments can be reused instead of adding

View File

@ -267,7 +267,7 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
private showSyncWarnings(warnings: string[]): void {
const message = CoreTextUtils.buildMessage(warnings);
if (message) {
CoreDomUtils.showErrorModal(message);
CoreDomUtils.showAlert(undefined, message);
}
}

View File

@ -216,7 +216,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
const result = await this.sync();
if (result.warnings.length) {
CoreDomUtils.showErrorModal(result.warnings[0]);
CoreDomUtils.showAlert(undefined, result.warnings[0]);
}
return this.hasSyncSucceed(result);

View File

@ -152,7 +152,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon
this.showLoadingAndRefresh(false, false);
if (data.warnings && data.warnings[0]) {
CoreDomUtils.showErrorModal(data.warnings[0]);
CoreDomUtils.showAlert(undefined, data.warnings[0].message);
}
});
}
@ -183,7 +183,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon
this.course.displayname || this.course.fullname,
));
if (result?.warnings?.length) {
CoreDomUtils.showErrorModal(result.warnings[0]);
CoreDomUtils.showAlert(undefined, result.warnings[0].message);
}
}

View File

@ -2389,6 +2389,11 @@ H5P.createTitle = function (rawTitle, maxLength) {
done('Not signed in.');
return;
}
// Moodle patch to let override this method.
if (H5P.contentUserDataAjax !== undefined) {
return H5P.contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async);
}
// End of Moodle patch.
var options = {
url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0),

View File

@ -77,6 +77,7 @@ H5PEmbedCommunicator = (function() {
*
* @param {string} component
* @param {Object} statements
* @returns {void}
*/
self.post = function(component, statements) {
window.parent.postMessage({
@ -87,6 +88,50 @@ H5PEmbedCommunicator = (function() {
statements: statements,
}, '*');
};
/**
* Send a xAPI state to LMS.
*
* @param {string} component
* @param {string} activityId
* @param {Object} agent
* @param {string} stateId
* @param {string} stateData
* @returns {void}
*/
self.postState = function(component, activityId, agent, stateId, stateData) {
window.parent.postMessage({
environment: 'moodleapp',
context: 'h5p',
action: 'xapi_post_state',
component: component,
activityId: activityId,
agent: agent,
stateId: stateId,
stateData: stateData,
}, '*');
};
/**
* Delete a xAPI state from LMS.
*
* @param {string} component
* @param {string} activityId
* @param {Object} agent
* @param {string} stateId
* @returns {void}
*/
self.deleteState = function(component, activityId, agent, stateId) {
window.parent.postMessage({
environment: 'moodleapp',
context: 'h5p',
action: 'xapi_delete_state',
component: component,
activityId: activityId,
agent: agent,
stateId: stateId,
}, '*');
};
}
return (window.postMessage && window.addEventListener ? new Communicator() : undefined);
@ -115,6 +160,9 @@ document.onreadystatechange = async() => {
return;
}
/** @var {boolean} statementPosted Whether the statement has been sent or not, to avoid sending xAPI State after it. */
var statementPosted = false;
// Check for H5P iFrame.
var iFrame = document.querySelector('.h5p-iframe');
if (!iFrame || !iFrame.contentWindow) {
@ -184,6 +232,7 @@ document.onreadystatechange = async() => {
// Get emitted xAPI data.
H5P.externalDispatcher.on('xAPI', function(event) {
statementPosted = false;
var moodlecomponent = H5P.getMoodleComponent();
if (moodlecomponent == undefined) {
return;
@ -211,6 +260,33 @@ document.onreadystatechange = async() => {
if (isCompleted && !isChild) {
var statements = H5P.getXAPIStatements(this.contentId, statement);
H5PEmbedCommunicator.post(moodlecomponent, statements);
// Mark the statement has been sent, to avoid sending xAPI State after it.
statementPosted = true;
}
});
H5P.externalDispatcher.on('xAPIState', function(event) {
var moodlecomponent = H5P.getMoodleComponent();
var contentId = event.data.activityId;
var stateId = event.data.stateId;
var state = event.data.state;
if (state === undefined) {
// When state is undefined, a call to the WS for getting the state could be done. However, for now, this is not
// required because the content state is initialised with PHP.
return;
}
if (state === null) {
// When this method is called from the H5P API with null state, the state must be deleted using the rest of attributes.
H5PEmbedCommunicator.deleteState(moodlecomponent, contentId, H5P.getxAPIActor(), stateId);
} else if (!statementPosted) {
// Only update the state if a statement hasn't been posted recently.
// When state is defined, it needs to be updated. As not all the H5P content types are returning a JSON, we need
// to simulate it because xAPI State defines statedata as a JSON.
var statedata = {
h5p: state
};
H5PEmbedCommunicator.postState(moodlecomponent, contentId, H5P.getxAPIActor(), stateId, JSON.stringify(statedata));
}
});

View File

@ -94,3 +94,72 @@ H5P.getMoodleComponent = function () {
};
}
};
/**
* Get the actor.
*
* @returns {Object} The Actor object.
*/
H5P.getxAPIActor = function() {
var actor = null;
if (H5PIntegration.user !== undefined) {
actor = {
'name': H5PIntegration.user.name,
'objectType': 'Agent'
};
if (H5PIntegration.user.id !== undefined) {
actor.account = {
'name': H5PIntegration.user.id,
'homePage': H5PIntegration.siteUrl
};
} else if (H5PIntegration.user.mail !== undefined) {
actor.mbox = 'mailto:' + H5PIntegration.user.mail;
}
} else {
var uuid;
try {
if (localStorage.H5PUserUUID) {
uuid = localStorage.H5PUserUUID;
} else {
uuid = H5P.createUUID();
localStorage.H5PUserUUID = uuid;
}
} catch (err) {
// LocalStorage and Cookies are probably disabled. Do not track the user.
uuid = 'not-trackable-' + H5P.createUUID();
}
actor = {
'account': {
'name': uuid,
'homePage': H5PIntegration.siteUrl
},
'objectType': 'Agent'
};
}
return actor;
};
/**
* Creates requests for inserting, updating and deleting content user data.
* It overrides the contentUserDataAjax private method in h5p.js.
*
* @param {number} contentId What content to store the data for.
* @param {string} dataType Identifies the set of data for this content.
* @param {string} subContentId Identifies sub content
* @param {function} [done] Callback when ajax is done.
* @param {object} [data] To be stored for future use.
* @param {boolean} [preload=false] Data is loaded when content is loaded.
* @param {boolean} [invalidate=false] Data is invalidated when content changes.
* @param {boolean} [async=true]
*/
H5P.contentUserDataAjax = function(contentId, dataType, subContentId, done, data, preload, invalidate, async) {
var instance = H5P.findInstanceFromId(contentId);
if (instance !== undefined) {
var xAPIState = {
activityId: H5P.XAPIEvent.prototype.getContentXAPIId(instance),
stateId: dataType,
state: data
};
H5P.externalDispatcher.trigger('xAPIState', xAPIState);
}
};

View File

@ -44,6 +44,10 @@ if (window.H5PIntegration && window.H5PIntegration.contents && location.search)
}
} else if (nameAndValue[0] == 'trackingUrl' && contentData) {
contentData.url = nameAndValue[1];
} else if (nameAndValue[0] == 'saveFreq' && nameAndValue[1] !== undefined) {
window.H5PIntegration.saveFreq = nameAndValue[1];
} else if (nameAndValue[0] == 'state' && nameAndValue[1] !== undefined && contentData) {
contentData.contentUserData = [{ state: decodeURIComponent(nameAndValue[1]) }];
}
});
}

View File

@ -6,3 +6,4 @@ Changes:
1. The h5p.js file has been modified to make fullscreen work in the Moodle app. In line 34, the code inside the condition document.documentElement.webkitRequestFullScreen has changed, the original code has been commented.
2. The h5p.js file has been modified to simulate a fake full screen in iOS. The H5P file now sends post messages to the app, and also listens to messages sent by the app to enter/exit full screen.
3. The embed.js file has been modified to remove optional chaining because it isn't supported in some old devices.
4. The h5p.js has been modified to include a call to contentUserDataAjax (this change was done in LMS too).

View File

@ -585,14 +585,14 @@ export class CoreH5PContentValidator {
}
/**
* Check params for a whitelist of allowed properties.
* Check params for a allowlist of allowed properties.
*
* @param params Object to filter.
* @param whitelist List of keys to keep.
* @param allowlist List of keys to keep.
*/
filterParams(params: Record<string, unknown>, whitelist: string[]): void {
filterParams(params: Record<string, unknown>, allowlist: string[]): void {
for (const key in params) {
if (whitelist.indexOf(key) == -1) {
if (allowlist.indexOf(key) == -1) {
delete params[key];
}
}
@ -625,7 +625,7 @@ export class CoreH5PContentValidator {
// Defuse all HTML entities.
text = text.replace(/&/g, '&amp;');
// Change back only well-formed entities in our whitelist:
// Change back only well-formed entities in our allowed list:
// Decimal numeric entities.
text = text.replace(/&amp;#([0-9]+;)/g, '&#$1');
// Hexadecimal numeric entities.

View File

@ -109,6 +109,10 @@ export class CoreH5PFramework {
const db = await CoreSites.getSiteDb(siteId);
// The user content should be reset (instead of removed), because this method is called when H5P content needs
// to be updated too (and the previous states must be kept, but reset).
await this.resetContentUserData(id, siteId);
await Promise.all([
// Delete the content data.
db.deleteRecords(CONTENT_TABLE_NAME, { id }),
@ -645,7 +649,8 @@ export class CoreH5PFramework {
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async resetContentUserData(contentId: number, siteId?: string): Promise<void> {
// Currently, we do not store user data for a content.
// In LMS, all the states of the component are deleted here.
// This isn't possible in the app because we lack the course ID, which is needed for example by h5pactivity.
}
/**

View File

@ -122,9 +122,10 @@ export class CoreH5PHelper {
throw new CoreError('Site info could not be fetched.');
}
// H5P doesn't currently support xAPI State. It implements a mechanism in contentUserDataAjax() in h5p.js to update user
// data. However, in our case, we're overriding this method to call the xAPI State web services.
const basePath = CoreFile.getBasePathInstant();
const ajaxPaths = {
xAPIResult: '',
contentUserData: '',
};
@ -144,7 +145,7 @@ export class CoreH5PHelper {
),
postUserStatistics: false,
ajax: ajaxPaths,
saveFreq: false,
saveFreq: false, // saveFreq will be overridden in params.js.
siteUrl: site.getURL(),
l10n: {
H5P: CoreH5P.h5pCore.getLocalization(), // eslint-disable-line @typescript-eslint/naming-convention
@ -240,7 +241,7 @@ export type CoreH5PCoreSettings = {
urlLibraries: string;
postUserStatistics: boolean;
ajax: {
xAPIResult: string;
xAPIResult?: string;
contentUserData: string;
};
saveFreq: boolean;

View File

@ -16,12 +16,12 @@ import { CoreFile } from '@services/file';
import { CoreSites } from '@services/sites';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { CoreXAPI } from '@features/xapi/services/xapi';
import { CoreH5P } from '../services/h5p';
import { CoreH5PCore, CoreH5PDisplayOptions, CoreH5PContentData, CoreH5PDependenciesFiles } from './core';
import { CoreH5PCoreSettings, CoreH5PHelper } from './helper';
import { CoreH5PStorage } from './storage';
import { CorePath } from '@singletons/path';
import { CoreXAPIIRI } from '@features/xapi/classes/iri';
/**
* Equivalent to Moodle's H5P player class.
@ -98,7 +98,7 @@ export class CoreH5PPlayer {
metadata: content.metadata,
contentUserData: [
{
state: '{}',
state: '{}', // state will be overridden in params.js to use the latest state when the package is played.
},
],
};
@ -272,6 +272,7 @@ export class CoreH5PPlayer {
component?: string,
contextId?: number,
siteId?: string,
otherOptions: CoreH5PGetContentUrlOptions = {},
): Promise<string> {
siteId = siteId || CoreSites.getCurrentSiteId();
@ -282,13 +283,19 @@ export class CoreH5PPlayer {
displayOptions = this.h5pCore.fixDisplayOptions(displayOptions || {}, data.id);
const params: Record<string, string> = {
const params: Record<string, string | number> = {
displayOptions: JSON.stringify(displayOptions),
component: component || '',
};
if (contextId) {
params.trackingUrl = await CoreXAPI.getUrl(contextId, 'activity', siteId);
params.trackingUrl = await CoreXAPIIRI.generate(contextId, 'activity', siteId);
}
if (otherOptions.saveFreq !== undefined) {
params.saveFreq = otherOptions.saveFreq;
}
if (otherOptions.state !== undefined) {
params.state = otherOptions.state;
}
return CoreUrlUtils.addParamsToUrl(path, params);
@ -420,3 +427,8 @@ type AssetsSettings = CoreH5PCoreSettings & {
[libString: string]: string;
};
};
export type CoreH5PGetContentUrlOptions = {
saveFreq?: number; // State save frequency (if enabled).
state?: string; // Current state.
};

View File

@ -45,6 +45,8 @@ export class CoreH5PIframeComponent implements OnChanges, OnDestroy {
@Input() trackComponent?: string; // Component to send xAPI events to.
@Input() contextId?: number; // Context ID. Required for tracking.
@Input() enableInAppFullscreen?: boolean; // Whether to enable our custom in-app fullscreen feature.
@Input() saveFreq?: number; // Save frequency (in seconds) if enabled.
@Input() state?: string; // Initial content state.
@Output() onIframeUrlSet = new EventEmitter<{src: string; online: boolean}>();
@Output() onIframeLoaded = new EventEmitter<void>();
@ -150,6 +152,11 @@ export class CoreH5PIframeComponent implements OnChanges, OnDestroy {
* @returns Promise resolved with the local URL.
*/
protected async getLocalUrl(): Promise<string | undefined> {
const otherOptions = {
saveFreq: this.saveFreq,
state: this.state,
};
try {
const url = await CoreH5P.h5pPlayer.getContentIndexFileUrl(
this.fileUrl!,
@ -157,6 +164,7 @@ export class CoreH5PIframeComponent implements OnChanges, OnDestroy {
this.trackComponent,
this.contextId,
this.siteId,
otherOptions,
);
return url;
@ -176,6 +184,7 @@ export class CoreH5PIframeComponent implements OnChanges, OnDestroy {
this.trackComponent,
this.contextId,
this.siteId,
otherOptions,
);
return url;

View File

@ -0,0 +1,53 @@
// (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 { CoreSites } from '@services/sites';
import { CorePath } from '@singletons/path';
/**
* xAPI IRI values generator.
*/
export class CoreXAPIIRI {
/**
* Generate a valid IRI element from a value and an optional type.
*
* @param value Value.
* @param type Type (e.g. 'activity'). Defaults to 'element'.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done.
*/
static async generate(value: string|number, type = 'element', siteId?: string): Promise<string> {
const site = await CoreSites.getSite(siteId);
return CorePath.concatenatePaths(site.getURL(), `xapi/${type}/${value}`);
}
/**
* Try to extract the original value from an IRI.
*
* @param iri IRI.
* @param type Type (e.g. 'activity'). Defaults to 'element'.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done.
*/
static async extract(iri: string, type = 'element', siteId?: string): Promise<string> {
const site = await CoreSites.getSite(siteId);
const baseUrl = CorePath.concatenatePaths(site.getURL(), `xapi/${type}/`);
return iri.replace(baseUrl, '');
}
}

View File

@ -0,0 +1,70 @@
// (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 { CoreSite } from '@classes/site';
/**
* Statement agent (user) object for xAPI structure checking and usage.
*/
export class CoreXAPIItemAgent {
protected constructor(protected data: Record<string, unknown>, protected user: CoreXAPIItemAgentUser) { }
/**
* Get agent's user.
*
* @returns User.
*/
getUser(): CoreXAPIItemAgentUser {
return this.user;
}
/**
* Get agent's data.
*
* @returns Data.
*/
getData(): Record<string, unknown> {
return this.data;
}
/**
* Create an item agent based on a certain's site user.
*
* @param site Site to use.
* @returns Item agent instance.
*/
static createFromSite(site: CoreSite): CoreXAPIItemAgent {
const username = site.getInfo()?.username ?? '';
const data = {
name: username,
objectType: 'Agent',
account: {
name: site.getUserId(),
homePage: site.getURL(),
},
};
return new CoreXAPIItemAgent(data, {
id: site.getUserId(),
username,
});
}
}
type CoreXAPIItemAgentUser = {
id: number;
username: string;
};

View File

@ -18,9 +18,10 @@ import { CoreSiteSchema } from '@services/sites';
* Database variables for CoreXAPIOfflineProvider service.
*/
export const STATEMENTS_TABLE_NAME = 'core_xapi_statements';
export const STATES_TABLE_NAME = 'core_xapi_states';
export const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreXAPIOfflineProvider',
version: 1,
version: 2,
tables: [
{
name: STATEMENTS_TABLE_NAME,
@ -57,6 +58,58 @@ export const SITE_SCHEMA: CoreSiteSchema = {
},
],
},
{
name: STATES_TABLE_NAME,
columns: [
{
name: 'id',
type: 'INTEGER',
primaryKey: true,
autoIncrement: true,
},
{
name: 'component',
type: 'TEXT',
notNull: true,
},
{
name: 'itemid',
type: 'INTEGER',
notNull: true,
},
{
name: 'stateid',
type: 'TEXT',
notNull: true,
},
{
name: 'statedata',
type: 'TEXT',
},
{
name: 'registration',
type: 'TEXT',
},
{
name: 'timecreated',
type: 'INTEGER',
notNull: true,
},
{
name: 'timemodified',
type: 'INTEGER',
notNull: true,
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'extra',
type: 'TEXT',
},
],
},
],
};
@ -72,3 +125,19 @@ export type CoreXAPIStatementDBRecord = {
courseid?: number; // Course ID if the context is inside a course.
extra?: string; // Extra data.
};
/**
* Structure of state data stored in DB.
*/
export type CoreXAPIStateDBRecord = {
id: number; // ID.
component: string; // Component name.
itemid: number; // The Agent Id (usually the plugin instance).
stateid: string; // Component identified for the state data.
statedata?: string; // JSON state data.
registration?: string; // Optional registration identifier.
timecreated: number; // When was the state modified.
timemodified: number; // When was the state modified.
courseid?: number; // Course ID if the activity is inside a course.
extra?: string; // Extra data.
};

View File

@ -15,8 +15,10 @@
import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { CoreXAPIStatementDBRecord, STATEMENTS_TABLE_NAME } from './database/xapi';
import { CoreXAPIStateDBRecord, CoreXAPIStatementDBRecord, STATEMENTS_TABLE_NAME, STATES_TABLE_NAME } from './database/xapi';
import { CoreXAPIStateOptions } from './xapi';
/**
* Service to handle offline xAPI.
@ -24,6 +26,22 @@ import { CoreXAPIStatementDBRecord, STATEMENTS_TABLE_NAME } from './database/xap
@Injectable({ providedIn: 'root' })
export class CoreXAPIOfflineProvider {
/**
* Check if there are offline data to send for a context.
*
* @param contextId Context ID.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with boolean: true if has offline data, false otherwise.
*/
async contextHasData(contextId: number, siteId?: string): Promise<boolean> {
const [hasStatements, hasSates] = await Promise.all([
this.contextHasStatements(contextId, siteId),
this.itemHasStates(contextId, siteId),
]);
return hasStatements || hasSates;
}
/**
* Check if there are offline statements to send for a context.
*
@ -63,6 +81,35 @@ export class CoreXAPIOfflineProvider {
await db.deleteRecords(STATEMENTS_TABLE_NAME, { contextid: contextId });
}
/**
* Delete all states from a component that fulfill the supplied condition.
*
* @param component Component.
* @param options Options.
* @returns Promise resolved when done.
*/
async deleteStates(
component: string,
options: CoreXAPIOfflineDeleteStatesOptions = {},
): Promise<void> {
const db = await CoreSites.getSiteDb(options.siteId);
const conditions: Partial<CoreXAPIStateDBRecord> = {
component,
};
if (options.itemId !== undefined) {
conditions.itemid = options.itemId;
}
if (options.stateId !== undefined) {
conditions.stateid = options.stateId;
}
if (options.registration !== undefined) {
conditions.registration = options.registration;
}
await db.deleteRecords(STATES_TABLE_NAME, conditions);
}
/**
* Get all offline statements.
*
@ -75,6 +122,18 @@ export class CoreXAPIOfflineProvider {
return db.getRecords(STATEMENTS_TABLE_NAME, undefined, 'timecreated ASC');
}
/**
* Get all offline states.
*
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with all the data.
*/
async getAllStates(siteId?: string): Promise<CoreXAPIStateDBRecord[]> {
const db = await CoreSites.getSiteDb(siteId);
return db.getRecords(STATES_TABLE_NAME, undefined, 'timemodified ASC');
}
/**
* Get statements for a context.
*
@ -88,6 +147,19 @@ export class CoreXAPIOfflineProvider {
return db.getRecords<CoreXAPIStatementDBRecord>(STATEMENTS_TABLE_NAME, { contextid: contextId }, 'timecreated ASC');
}
/**
* Get states for an item.
*
* @param itemId item ID.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with the data.
*/
async getItemStates(itemId: number, siteId?: string): Promise<CoreXAPIStateDBRecord[]> {
const db = await CoreSites.getSiteDb(siteId);
return db.getRecords<CoreXAPIStateDBRecord>(STATES_TABLE_NAME, { itemid: itemId }, 'timecreated ASC');
}
/**
* Get certain statements.
*
@ -101,6 +173,98 @@ export class CoreXAPIOfflineProvider {
return db.getRecord<CoreXAPIStatementDBRecord>(STATEMENTS_TABLE_NAME, { id });
}
/**
* Get a certain state (if it exists).
*
* @param component Component.
* @param itemId The Agent Id (usually the plugin instance).
* @param stateId The xAPI state ID.
* @param options Options.
* @returns Promise resolved when done.
*/
async getState(
component: string,
itemId: number,
stateId: string,
options: CoreXAPIStateOptions = {},
): Promise<CoreXAPIStateDBRecord> {
const db = await CoreSites.getSiteDb(options?.siteId);
const conditions: Partial<CoreXAPIStateDBRecord> = {
component,
itemid: itemId,
stateid: stateId,
};
if (options.registration) {
conditions.registration = options.registration;
}
return db.getRecord(STATES_TABLE_NAME, conditions);
}
/**
* Check if there are offline states to send for an item.
*
* @param itemId Item ID.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with boolean: true if has offline states, false otherwise.
*/
async itemHasStates(itemId: number, siteId?: string): Promise<boolean> {
const statesList = await this.getItemStates(itemId, siteId);
return statesList && statesList.length > 0;
}
/**
* Save state.
*
* @param component Component.
* @param itemId The Agent Id (usually the plugin instance).
* @param stateId The xAPI state ID.
* @param stateData JSON object with the state data.
* @param options Options.
* @returns Promise resolved when state is successfully saved.
*/
async saveState(
component: string,
itemId: number,
stateId: string,
stateData: string,
options: CoreXAPIOfflineSaveStateOptions = {},
): Promise<void> {
const db = await CoreSites.getSiteDb(options?.siteId);
const storedState = await CoreUtils.ignoreErrors(this.getState(component, itemId, stateId, options));
if (storedState) {
const newData: Partial<CoreXAPIStateDBRecord> = {
statedata: stateData,
timemodified: Date.now(),
};
const conditions: Partial<CoreXAPIStateDBRecord> = {
component,
itemid: itemId,
stateid: stateId,
registration: options?.registration,
};
await db.updateRecords(STATES_TABLE_NAME, newData, conditions);
} else {
const entry: Omit<CoreXAPIStateDBRecord, 'id'> = {
component,
itemid: itemId,
stateid: stateId,
statedata: stateData,
timecreated: Date.now(),
timemodified: Date.now(),
courseid: options?.courseId,
extra: options?.extra,
};
await db.insertRecord(STATES_TABLE_NAME, entry);
}
}
/**
* Save statements.
*
@ -135,10 +299,30 @@ export class CoreXAPIOfflineProvider {
export const CoreXAPIOffline = makeSingleton(CoreXAPIOfflineProvider);
/**
* Options to pass to saveStatements function.
* Common options to pass to save functions.
*/
export type CoreXAPIOfflineSaveStatementsOptions = {
export type CoreXAPIOfflineSaveCommonOptions = {
courseId?: number; // Course ID if the context is inside a course.
extra?: string; // Extra data to store.
siteId?: string; // Site ID. If not defined, current site.
};
/**
* Options to pass to saveStatements function.
*/
export type CoreXAPIOfflineSaveStatementsOptions = CoreXAPIOfflineSaveCommonOptions;
/**
* Options to pass to saveStatements function.
*/
export type CoreXAPIOfflineSaveStateOptions = CoreXAPIOfflineSaveCommonOptions & {
registration?: string; // The xAPI registration UUID.
};
/**
* Options to pass to deleteStates function.
*/
export type CoreXAPIOfflineDeleteStatesOptions = CoreXAPIStateOptions & {
itemId?: number;
stateId?: string;
};

View File

@ -15,12 +15,17 @@
import { Injectable } from '@angular/core';
import { CoreNetwork } from '@services/network';
import { CoreSites } from '@services/sites';
import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreSite } from '@classes/site';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreXAPIOffline, CoreXAPIOfflineSaveStatementsOptions } from './offline';
import { makeSingleton } from '@singletons';
import { CorePath } from '@singletons/path';
import { CoreXAPIItemAgent } from '../classes/item-agent';
import { CoreXAPIIRI } from '../classes/iri';
import { CoreError } from '@classes/errors/error';
import { CoreLogger } from '@singletons/logger';
export const XAPI_STATE_DELETED = 'STATE_DELETED';
/**
* Service to provide XAPI functionalities.
@ -28,6 +33,10 @@ import { CorePath } from '@singletons/path';
@Injectable({ providedIn: 'root' })
export class CoreXAPIProvider {
static readonly ROOT_CACHE_KEY = 'CoreXAPI:';
protected logger = CoreLogger.getInstance('CoreXAPIProvider');
/**
* Returns whether or not WS to post XAPI statement is available.
*
@ -54,6 +63,229 @@ export class CoreXAPIProvider {
return !!(site && site.wsAvailable('core_xapi_statement_post'));
}
/**
* Delete a state both online and offline.
*
* @param component Component.
* @param activityIRI XAPI activity ID IRI.
* @param agent The xAPI agent data.
* @param stateId The xAPI state ID.
* @param options Options.
* @returns Promise resolved when done.
*/
async deleteState(
component: string,
activityIRI: string,
agent: Record<string, unknown>,
stateId: string,
options: CoreXAPIStateSendDataOptions = {},
): Promise<boolean> {
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
const storeOffline = async (): Promise<boolean> => {
const itemIdString = await CoreXAPIIRI.extract(activityIRI, 'activity', options.siteId);
const itemId = Number(itemIdString);
if (isNaN(itemId)) {
throw new CoreError('Invalid activity ID sent to xAPI delete state.');
}
// Save an offline state as deleted.
await CoreXAPIOffline.saveState(component, itemId, stateId, XAPI_STATE_DELETED, options);
return false;
};
if (!CoreNetwork.isOnline() || options.offline) {
// App is offline, store the action.
return storeOffline();
}
try {
await this.deleteStateOnline(component, activityIRI, JSON.stringify(agent), stateId, options);
const itemIdString = await CoreXAPIIRI.extract(activityIRI, 'activity', options.siteId);
const itemId = Number(itemIdString);
if (!isNaN(itemId)) {
// Delete offline state if it exists.
await CoreUtils.ignoreErrors(CoreXAPIOffline.deleteStates(component, {
itemId,
stateId,
registration: options.registration,
siteId: options.siteId,
}));
}
return true;
} catch (error) {
if (CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that the state cannot be deleted.
throw error;
}
// Couldn't connect to server, store it offline.
try {
return await storeOffline();
} catch (offlineError) {
this.logger.error('Error storing a DELETED xAPI state in offline storage.', offlineError);
throw error;
}
}
}
/**
* Delete state. It will fail if offline or cannot connect.
*
* @param component Component.
* @param activityIRI XAPI activity ID IRI.
* @param agent The xAPI agent json.
* @param stateId The xAPI state ID.
* @param options Options.
* @returns Promise resolved when done.
*/
async deleteStateOnline(
component: string,
activityIRI: string,
agent: string,
stateId: string,
options: CoreXAPIStateOptions = {},
): Promise<boolean> {
const site = await CoreSites.getSite(options.siteId);
const data: CoreXAPIDeleteStateWSParams = {
component,
activityId: activityIRI,
agent,
stateId,
registration: options.registration,
};
return site.write('core_xapi_delete_state', data, { typeExpected: 'boolean' });
}
/**
* Get state from WS.
*
* @param component Component.
* @param activityId Activity ID.
* @param stateId The xAPI state ID.
* @param options Options.
* @returns Promise resolved when done.
*/
async getState(
component: string,
activityId: number,
stateId: string,
options: CoreXAPIGetStateOptions = {},
): Promise<string | null> {
try {
const offlineState = await CoreXAPIOffline.getState(component, activityId, stateId, options);
return offlineState.statedata !== XAPI_STATE_DELETED ? (offlineState.statedata ?? null) : null;
} catch {
// No offline state.
}
return this.getStateFromServer(component, activityId, stateId, options);
}
/**
* Get cache key for H5P get state WS calls.
*
* @param siteUrl Site URL.
* @param component Component.
* @param activityId Activity ID.
* @param stateId The xAPI state ID.
* @param registration Registration ID.
* @returns Cache key.
*/
protected getStateCacheKey(
siteUrl: string,
component: string,
activityId: number,
stateId: string,
registration?: string,
): string {
return `${CoreXAPIProvider.ROOT_CACHE_KEY}state:${component}:${activityId}:${stateId}:${registration ?? ''}`;
}
/**
* Get state from WS.
*
* @param component Component.
* @param activityId Activity ID.
* @param stateId The xAPI state ID.
* @param options Options.
* @returns Promise resolved when done.
*/
async getStateFromServer(
component: string,
activityId: number,
stateId: string,
options: CoreXAPIGetStateOptions = {},
): Promise<string | null> {
const [site, activityIRI] = await Promise.all([
CoreSites.getSite(options.siteId),
CoreXAPIIRI.generate(activityId, 'activity'),
]);
const data: CoreXAPIGetStateWSParams = {
component,
activityId: activityIRI,
agent: JSON.stringify(CoreXAPIItemAgent.createFromSite(site).getData()),
stateId,
registration: options.registration,
};
const preSets: CoreSiteWSPreSets = {
typeExpected: 'jsonstring',
cacheKey: this.getStateCacheKey(
site.getURL(),
component,
activityId,
stateId,
options.registration,
),
component: options.appComponent,
componentId: options.appComponentId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('core_xapi_get_state', data, preSets);
}
/**
* Get states after a certain timestamp. It will fail if offline or cannot connect.
*
* @param component Component.
* @param activityId Activity ID.
* @param options Options.
* @returns Promise resolved when done.
*/
async getStatesSince(
component: string,
activityId: number,
options: CoreXAPIGetStatesOptions = {},
): Promise<string[]> {
const [site, activityIRI] = await Promise.all([
CoreSites.getSite(options.siteId),
CoreXAPIIRI.generate(activityId, 'activity'),
]);
const data: CoreXAPIGetStatesWSParams = {
component,
activityId: activityIRI,
agent: JSON.stringify(CoreXAPIItemAgent.createFromSite(site).getData()),
registration: options.registration,
};
if (options.since) {
data.since = String(Math.floor(options.since / 1000));
}
return site.write<string[]>('core_xapi_get_states', data);
}
/**
* Get URL for XAPI events.
*
@ -61,11 +293,36 @@ export class CoreXAPIProvider {
* @param type Type (e.g. 'activity').
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done.
* @deprecated since 4.2. Use CoreXAPIIRI.generate instead.
*/
async getUrl(contextId: number, type: string, siteId?: string): Promise<string> {
const site = await CoreSites.getSite(siteId);
return CoreXAPIIRI.generate(contextId, type, siteId);
}
return CorePath.concatenatePaths(site.getURL(), `xapi/${type}/${contextId}`);
/**
* Invalidates a state.
*
* @param component Component.
* @param activityId Activity ID.
* @param stateId The xAPI state ID.
* @param options Options.
* @returns Promise resolved when the data is invalidated.
*/
async invalidateState(
component: string,
activityId: number,
stateId: string,
options: CoreXAPIStateOptions = {},
): Promise<void> {
const site = await CoreSites.getSite(options.siteId);
await site.invalidateWsCacheForKey(this.getStateCacheKey(
site.getURL(),
component,
activityId,
stateId,
options.registration,
));
}
/**
@ -81,13 +338,10 @@ export class CoreXAPIProvider {
contextId: number,
component: string,
json: string,
options?: CoreXAPIPostStatementsOptions,
options: CoreXAPIPostStatementsOptions = {},
): Promise<boolean> {
options = options || {};
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
// Convenience function to store a message to be synchronized later.
const storeOffline = async (): Promise<boolean> => {
await CoreXAPIOffline.saveStatements(contextId, component, json, options);
@ -105,7 +359,7 @@ export class CoreXAPIProvider {
return true;
} catch (error) {
if (CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
// The WebService has thrown an error, this means that statements cannot be submitted.
throw error;
} else {
// Couldn't connect to server, store it offline.
@ -122,11 +376,11 @@ export class CoreXAPIProvider {
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done.
*/
async postStatementsOnline(component: string, json: string, siteId?: string): Promise<number[]> {
async postStatementsOnline(component: string, json: string, siteId?: string): Promise<boolean[]> {
const site = await CoreSites.getSite(siteId);
const data = {
const data: CoreXAPIStatementPostWSParams = {
component: component,
requestjson: json,
};
@ -134,6 +388,99 @@ export class CoreXAPIProvider {
return site.write('core_xapi_statement_post', data);
}
/**
* Post state. It will fail if offline or cannot connect.
*
* @param component Component.
* @param activityIRI XAPI activity ID IRI.
* @param agent The xAPI agent data.
* @param stateId The xAPI state ID.
* @param stateData JSON object with the state data.
* @param options Options.
* @returns Promise resolved when done.
*/
async postState(
component: string,
activityIRI: string,
agent: Record<string, unknown>,
stateId: string,
stateData: string,
options: CoreXAPIStateSendDataOptions = {},
): Promise<boolean> {
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
const storeOffline = async (): Promise<boolean> => {
const itemIdString = await CoreXAPIIRI.extract(activityIRI, 'activity', options.siteId);
const itemId = Number(itemIdString);
if (isNaN(itemId)) {
throw new CoreError('Invalid activity ID sent to xAPI post state.');
}
await CoreXAPIOffline.saveState(component, itemId, stateId, stateData, options);
return false;
};
if (!CoreNetwork.isOnline() || options.offline) {
// App is offline, store the action.
return storeOffline();
}
try {
await this.postStateOnline(component, activityIRI, JSON.stringify(agent), stateId, stateData, options);
return true;
} catch (error) {
if (CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that state cannot be submitted.
throw error;
}
// Couldn't connect to server, store it offline.
try {
return await storeOffline();
} catch (offlineError) {
this.logger.error('Error storing xAPI state in offline storage.', offlineError);
throw error;
}
}
}
/**
* Post state. It will fail if offline or cannot connect.
*
* @param component Component.
* @param activityIRI XAPI activity ID IRI.
* @param agent The xAPI agent json.
* @param stateId The xAPI state ID.
* @param stateData JSON object with the state data.
* @param options Options.
* @returns Promise resolved when done.
*/
async postStateOnline(
component: string,
activityIRI: string,
agent: string,
stateId: string,
stateData: string,
options: CoreXAPIStateOptions = {},
): Promise<boolean> {
const site = await CoreSites.getSite(options.siteId);
const data: CoreXAPIPostStateWSParams = {
component,
activityId: activityIRI,
agent,
stateId,
stateData,
registration: options.registration,
};
return site.write('core_xapi_post_state', data, { typeExpected: 'boolean' });
}
}
export const CoreXAPI = makeSingleton(CoreXAPIProvider);
@ -144,3 +491,86 @@ export const CoreXAPI = makeSingleton(CoreXAPIProvider);
export type CoreXAPIPostStatementsOptions = CoreXAPIOfflineSaveStatementsOptions & {
offline?: boolean; // Whether to force storing it in offline.
};
/**
* Params of core_xapi_statement_post WS.
*/
export type CoreXAPIStatementPostWSParams = {
component: string; // Component name.
requestjson: string; // Json object with all the statements to post.
};
/**
* Options to pass to state functions.
*/
export type CoreXAPIStateOptions = {
registration?: string; // The xAPI registration UUID.
siteId?: string;
};
/**
* Options to pass to getState function.
*/
export type CoreXAPIGetStateOptions = CoreXAPIStateOptions & CoreSitesCommonWSOptions & {
appComponent?: string; // The app component to link the WS call to.
appComponentId?: number; // The app component ID to link the WS call to.
};
/**
* Options to pass to getStatesSince function.
*/
export type CoreXAPIGetStatesOptions = CoreXAPIStateOptions & {
since?: number; // Timestamp (in milliseconds) to filter the states.
};
/**
* Options to pass to postState and deleteState functions.
*/
export type CoreXAPIStateSendDataOptions = CoreXAPIStateOptions & {
offline?: boolean; // Whether to force storing it in offline.
};
/**
* Params of core_xapi_post_state WS.
*/
export type CoreXAPIPostStateWSParams = {
component: string; // Component name.
activityId: string; // XAPI activity ID IRI.
agent: string; // The xAPI agent json.
stateId: string; // The xAPI state ID.
stateData: string; // JSON object with the state data.
registration?: string; // The xAPI registration UUID.
};
/**
* Params of core_xapi_delete_state WS.
*/
export type CoreXAPIDeleteStateWSParams = {
component: string; // Component name.
activityId: string; // XAPI activity ID IRI.
agent: string; // The xAPI agent json.
stateId: string; // The xAPI state ID.
registration?: string; // The xAPI registration UUID.
};
/**
* Params of core_xapi_get_state WS.
*/
export type CoreXAPIGetStateWSParams = {
component: string; // Component name.
activityId: string; // XAPI activity ID IRI.
agent: string; // The xAPI agent json.
stateId: string; // The xAPI state ID.
registration?: string; // The xAPI registration UUID.
};
/**
* Params of core_xapi_get_states WS.
*/
export type CoreXAPIGetStatesWSParams = {
component: string; // Component name.
activityId: string; // XAPI activity ID IRI.
agent: string; // The xAPI agent json.
registration?: string; // The xAPI registration UUID.
since?: string; // Filter ids stored since the timestamp (exclusive).
};

View File

@ -175,6 +175,7 @@
"misc": "Miscellaneous",
"mod_assign": "Assignment",
"mod_book": "Book",
"mod_bigbluebuttonbn": "BigBlueButton",
"mod_chat": "Chat",
"mod_choice": "Choice",
"mod_data": "Database",
@ -353,7 +354,8 @@
"viewembeddedcontent": "View embedded content",
"viewprofile": "View profile",
"wanttochangesite": "Want to change sites or log out?",
"warningofflinedatadeleted": "Offline data from {{component}} '{{name}}' has been deleted. {{error}}",
"warningofflinedatadeleted": "Offline changes to {{component}} '{{name}}' have been discarded. {{error}}",
"warningofflinedatadeletedreason": "Newer changes to this activity have been made from a different device.",
"warnopeninbrowser": "<p>You are about to leave the app to open the following URL in your device's browser. Do you want to continue?</p>\n<p><b>{{url}}</b></p>",
"week": "Week",
"weeks": "weeks",

View File

@ -647,12 +647,13 @@ export class CoreWSProvider {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return promise.then(async (data: any) => {
// Some moodle web services return null.
// If the responseExpected value is set to false, we create a blank object if the response is null.
if (!data && !preSets.responseExpected) {
data = {};
// Some moodle web services always return null, and some others can return a primitive type or null.
if (data === null && (!preSets.responseExpected || preSets.typeExpected !== 'object')) {
return null;
}
const typeExpected = preSets.typeExpected === 'jsonstring' ? 'string' : preSets.typeExpected;
if (!data) {
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
errorcode: 'serverconnectionpost',
@ -660,26 +661,26 @@ export class CoreWSProvider {
details: Translate.instant('core.errorinvalidresponse', { method }),
}),
});
} else if (typeof data != preSets.typeExpected) {
} else if (typeof data !== typeExpected) {
// If responseType is text an string will be returned, parse before returning.
if (typeof data == 'string') {
if (preSets.typeExpected == 'number') {
if (typeExpected === 'number') {
data = Number(data);
if (isNaN(data)) {
this.logger.warn(`Response expected type "${preSets.typeExpected}" cannot be parsed to number`);
this.logger.warn(`Response expected type "${typeExpected}" cannot be parsed to number`);
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
errorcode: 'invalidresponse',
errorDetails: Translate.instant('core.errorinvalidresponse', { method }),
});
}
} else if (preSets.typeExpected == 'boolean') {
} else if (typeExpected === 'boolean') {
if (data === 'true') {
data = true;
} else if (data === 'false') {
data = false;
} else {
this.logger.warn(`Response expected type "${preSets.typeExpected}" is not true or false`);
this.logger.warn(`Response expected type "${typeExpected}" is not true or false`);
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
errorcode: 'invalidresponse',
@ -687,7 +688,7 @@ export class CoreWSProvider {
});
}
} else {
this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`);
this.logger.warn('Response of type "' + typeof data + `" received, expecting "${typeExpected}"`);
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
errorcode: 'invalidresponse',
@ -695,7 +696,7 @@ export class CoreWSProvider {
});
}
} else {
this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`);
this.logger.warn('Response of type "' + typeof data + `" received, expecting "${typeExpected}"`);
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
errorcode: 'invalidresponse',
@ -1296,7 +1297,7 @@ export type CoreWSPreSets = {
/**
* Defaults to 'object'. Use it when you expect a type that's not an object|array.
*/
typeExpected?: string;
typeExpected?: CoreWSTypeExpected;
/**
* Defaults to false. Clean multibyte Unicode chars from data.
@ -1310,6 +1311,8 @@ export type CoreWSPreSets = {
splitRequest?: CoreWSPreSetsSplitRequest;
};
export type CoreWSTypeExpected = 'boolean'|'number'|'string'|'jsonstring'|'object';
/**
* Options to split a request.
*/