From 8cb4b4ec6d75bab0803abbea8850103cdae730bf Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 31 Mar 2023 14:56:00 +0200 Subject: [PATCH] MOBILE-4269 h5pactivity: Support save state in offline --- scripts/langindex.json | 1 + .../mod/h5pactivity/components/index/index.ts | 56 ++++- .../h5pactivity/services/h5pactivity-sync.ts | 235 ++++++++++++++++-- .../mod/h5pactivity/services/h5pactivity.ts | 14 ++ .../h5pactivity/services/handlers/prefetch.ts | 75 +++++- src/core/features/h5p/classes/framework.ts | 7 +- .../features/xapi/services/database/xapi.ts | 71 +++++- src/core/features/xapi/services/offline.ts | 190 +++++++++++++- src/core/features/xapi/services/xapi.ts | 230 ++++++++++++++++- src/core/lang.json | 3 +- 10 files changed, 829 insertions(+), 53 deletions(-) diff --git a/scripts/langindex.json b/scripts/langindex.json index fd08a1599..04e393b71 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2521,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", diff --git a/src/addons/mod/h5pactivity/components/index/index.ts b/src/addons/mod/h5pactivity/components/index/index.ts index 36460d26b..b84fc38bd 100644 --- a/src/addons/mod/h5pactivity/components/index/index.ts +++ b/src/addons/mod/h5pactivity/components/index/index.ts @@ -39,6 +39,7 @@ import { AddonModH5PActivityXAPIPostStateData, AddonModH5PActivityXAPIStateData, AddonModH5PActivityXAPIStatementsData, + MOD_H5PACTIVITY_STATE_ID, } from '../../services/h5pactivity'; import { AddonModH5PActivitySync, @@ -48,6 +49,7 @@ import { 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. @@ -114,13 +116,17 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv async ngOnInit(): Promise { super.ngOnInit(); - this.loadContent(); + this.loadContent(false, true); } /** * @inheritdoc */ protected async fetchContent(refresh?: boolean, sync = false, showErrors = false): Promise { + // 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, }); @@ -176,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); } /** @@ -229,7 +235,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv * Load the content's state (if enabled and there's any). */ protected async loadContentState(): Promise { - if (!this.h5pActivity?.enabletracking || !this.h5pActivity.enablesavestate || !this.accessInfo?.cansubmit) { + if (!this.h5pActivity || !this.accessInfo || !AddonModH5PActivity.isSaveStateEnabled(this.h5pActivity, this.accessInfo)) { this.saveStateEnabled = false; return; @@ -238,10 +244,10 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv this.saveStateEnabled = true; this.saveFreq = this.h5pActivity.savestatefreq; - const contentState = await CoreXAPI.getStateOnline( + const contentState = await CoreXAPI.getState( AddonModH5PActivityProvider.TRACK_COMPONENT, this.h5pActivity.context, - 'state', + MOD_H5PACTIVITY_STATE_ID, { appComponent: AddonModH5PActivityProvider.COMPONENT, appComponentId: this.h5pActivity.coursemodule, @@ -256,7 +262,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv 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; + this.contentState = contentStateObj.h5p ?? '{}'; } /** @@ -381,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(), @@ -567,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 { @@ -626,16 +636,23 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv */ protected async postState(data: AddonModH5PActivityXAPIPostStateData): Promise { try { - await CoreXAPI.postStateOnline( + 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, - JSON.stringify(data.agent), + data.agent, data.stateId, data.stateData, - { - siteId: this.site.getId(), - }, + options, ); + + this.hasOffline = !sent; } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error sending tracking data.'); } @@ -648,10 +665,10 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv */ protected async deleteState(data: AddonModH5PActivityXAPIStateData): Promise { try { - await CoreXAPI.deleteStateOnline( + await CoreXAPI.deleteState( data.component, data.activityId, - JSON.stringify(data.agent), + data.agent, data.stateId, { siteId: this.site.getId(), @@ -662,6 +679,19 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv } } + /** + * Delete offline states for current activity. + */ + protected async deleteOfflineStates(): Promise { + if (!this.h5pActivity) { + return; + } + + await CoreUtils.ignoreErrors(CoreXAPIOffline.deleteStates(AddonModH5PActivityProvider.TRACK_COMPONENT, { + itemId: this.h5pActivity.context, + })); + } + /** * @inheritdoc */ diff --git a/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts b/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts index 7d9c3fb19..1189c3d3e 100644 --- a/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts +++ b/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts @@ -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 { - 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 => { + 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 => { 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 { + 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 { + 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; } diff --git a/src/addons/mod/h5pactivity/services/h5pactivity.ts b/src/addons/mod/h5pactivity/services/h5pactivity.ts index 7f21203ab..594e0d201 100644 --- a/src/addons/mod/h5pactivity/services/h5pactivity.ts +++ b/src/addons/mod/h5pactivity/services/h5pactivity.ts @@ -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:'; /** @@ -759,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. * @@ -1210,4 +1223,5 @@ export type AddonModH5PActivityStatement = { id: string; display: Record; }; + timestamp?: string; }; diff --git a/src/addons/mod/h5pactivity/services/handlers/prefetch.ts b/src/addons/mod/h5pactivity/services/handlers/prefetch.ts index a4202cb41..cc1eb09b3 100644 --- a/src/addons/mod/h5pactivity/services/handlers/prefetch.ts +++ b/src/addons/mod/h5pactivity/services/handlers/prefetch.ts @@ -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 { - 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 { 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 { + 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); diff --git a/src/core/features/h5p/classes/framework.ts b/src/core/features/h5p/classes/framework.ts index b3dfbe603..89921a8ab 100644 --- a/src/core/features/h5p/classes/framework.ts +++ b/src/core/features/h5p/classes/framework.ts @@ -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 { - // 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. } /** diff --git a/src/core/features/xapi/services/database/xapi.ts b/src/core/features/xapi/services/database/xapi.ts index 6164804fd..8f58ede98 100644 --- a/src/core/features/xapi/services/database/xapi.ts +++ b/src/core/features/xapi/services/database/xapi.ts @@ -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. +}; diff --git a/src/core/features/xapi/services/offline.ts b/src/core/features/xapi/services/offline.ts index e0f01d503..732fdffd6 100644 --- a/src/core/features/xapi/services/offline.ts +++ b/src/core/features/xapi/services/offline.ts @@ -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 { + 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 { + const db = await CoreSites.getSiteDb(options.siteId); + + const conditions: Partial = { + 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 { + 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(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 { + const db = await CoreSites.getSiteDb(siteId); + + return db.getRecords(STATES_TABLE_NAME, { itemid: itemId }, 'timecreated ASC'); + } + /** * Get certain statements. * @@ -101,6 +173,98 @@ export class CoreXAPIOfflineProvider { return db.getRecord(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 { + const db = await CoreSites.getSiteDb(options?.siteId); + + const conditions: Partial = { + 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 { + 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 { + const db = await CoreSites.getSiteDb(options?.siteId); + + const storedState = await CoreUtils.ignoreErrors(this.getState(component, itemId, stateId, options)); + + if (storedState) { + const newData: Partial = { + statedata: stateData, + timemodified: Date.now(), + }; + const conditions: Partial = { + component, + itemid: itemId, + stateid: stateId, + registration: options?.registration, + }; + + await db.updateRecords(STATES_TABLE_NAME, newData, conditions); + } else { + const entry: Omit = { + 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; +}; diff --git a/src/core/features/xapi/services/xapi.ts b/src/core/features/xapi/services/xapi.ts index 094b24166..fd30ccb24 100644 --- a/src/core/features/xapi/services/xapi.ts +++ b/src/core/features/xapi/services/xapi.ts @@ -22,6 +22,10 @@ import { CoreXAPIOffline, CoreXAPIOfflineSaveStatementsOptions } from './offline import { makeSingleton } from '@singletons'; 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. @@ -31,6 +35,8 @@ 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. * @@ -57,6 +63,78 @@ 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, + stateId: string, + options: CoreXAPIStateSendDataOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + const storeOffline = async (): Promise => { + 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. * @@ -88,7 +166,33 @@ export class CoreXAPIProvider { } /** - * Get cache key for H5P activity data WS calls. + * 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 { + 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. @@ -116,7 +220,7 @@ export class CoreXAPIProvider { * @param options Options. * @returns Promise resolved when done. */ - async getStateOnline( + async getStateFromServer( component: string, activityId: number, stateId: string, @@ -151,6 +255,37 @@ export class CoreXAPIProvider { 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 { + 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('core_xapi_get_states', data); + } + /** * Get URL for XAPI events. * @@ -203,13 +338,10 @@ export class CoreXAPIProvider { contextId: number, component: string, json: string, - options?: CoreXAPIPostStatementsOptions, + options: CoreXAPIPostStatementsOptions = {}, ): Promise { - - options = options || {}; options.siteId = options.siteId || CoreSites.getCurrentSiteId(); - // Convenience function to store a message to be synchronized later. const storeOffline = async (): Promise => { await CoreXAPIOffline.saveStatements(contextId, component, json, options); @@ -227,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. @@ -256,6 +388,66 @@ 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, + stateId: string, + stateData: string, + options: CoreXAPIStateSendDataOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + const storeOffline = async (): Promise => { + 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. * @@ -273,7 +465,7 @@ export class CoreXAPIProvider { agent: string, stateId: string, stateData: string, - options: CoreXAPIPostStateOptions = {}, + options: CoreXAPIStateOptions = {}, ): Promise { const site = await CoreSites.getSite(options.siteId); @@ -325,9 +517,16 @@ export type CoreXAPIGetStateOptions = CoreXAPIStateOptions & CoreSitesCommonWSOp }; /** - * Options to pass to postState function. + * Options to pass to getStatesSince function. */ -export type CoreXAPIPostStateOptions = CoreXAPIStateOptions & { +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. }; @@ -364,3 +563,14 @@ export type CoreXAPIGetStateWSParams = { 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). +}; diff --git a/src/core/lang.json b/src/core/lang.json index 7a3e49312..15db74956 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -354,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": "

You are about to leave the app to open the following URL in your device's browser. Do you want to continue?

\n

{{url}}

", "week": "Week", "weeks": "weeks",