MOBILE-4269 h5pactivity: Support save state in offline

main
Dani Palou 2023-03-31 14:56:00 +02:00
parent abac134cc5
commit 8cb4b4ec6d
10 changed files with 829 additions and 53 deletions

View File

@ -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",

View File

@ -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<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,
});
@ -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<void> {
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<void> {
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<void> {
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<void> {
if (!this.h5pActivity) {
return;
}
await CoreUtils.ignoreErrors(CoreXAPIOffline.deleteStates(AddonModH5PActivityProvider.TRACK_COMPONENT, {
itemId: this.h5pActivity.context,
}));
}
/**
* @inheritdoc
*/

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:';
/**
@ -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<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

@ -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

@ -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

@ -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<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.
*
@ -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<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.
@ -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<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.
*
@ -203,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);
@ -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<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.
*
@ -273,7 +465,7 @@ export class CoreXAPIProvider {
agent: string,
stateId: string,
stateData: string,
options: CoreXAPIPostStateOptions = {},
options: CoreXAPIStateOptions = {},
): Promise<boolean> {
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).
};

View File

@ -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": "<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",