From 159056a6fbbe4c99cffcc11693d7111157d44d8e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 8 Jun 2020 15:45:44 +0200 Subject: [PATCH] MOBILE-3412: Support offline and sync --- scripts/langindex.json | 2 + .../index/addon-mod-h5pactivity-index.html | 8 +- .../mod/h5pactivity/components/index/index.ts | 92 +++++++- .../mod/h5pactivity/h5pactivity.module.ts | 11 +- .../mod/h5pactivity/pages/index/index.ts | 2 +- .../mod/h5pactivity/providers/h5pactivity.ts | 14 ++ .../providers/sync-cron-handler.ts | 46 ++++ src/addon/mod/h5pactivity/providers/sync.ts | 223 ++++++++++++++++++ src/assets/lang/en.json | 1 + src/core/compile/providers/compile.ts | 3 +- .../course/classes/main-resource-component.ts | 4 +- src/core/xapi/providers/offline.ts | 201 ++++++++++++++++ src/core/xapi/providers/xapi.ts | 62 ++++- src/core/xapi/xapi.module.ts | 3 + src/lang/en.json | 1 + 15 files changed, 656 insertions(+), 17 deletions(-) create mode 100644 src/addon/mod/h5pactivity/providers/sync-cron-handler.ts create mode 100644 src/addon/mod/h5pactivity/providers/sync.ts create mode 100644 src/core/xapi/providers/offline.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index 9df725629..8e06a8e3f 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1408,6 +1408,7 @@ "core.confirmdeletefile": "repository", "core.confirmgotabroot": "local_moodlemobileapp", "core.confirmgotabrootdefault": "local_moodlemobileapp", + "core.confirmleaveunknownchanges": "local_moodlemobileapp", "core.confirmloss": "local_moodlemobileapp", "core.confirmopeninbrowser": "local_moodlemobileapp", "core.considereddigitalminor": "moodle", @@ -1861,6 +1862,7 @@ "core.mod_folder": "folder/pluginname", "core.mod_forum": "forum/pluginname", "core.mod_glossary": "glossary/pluginname", + "core.mod_h5pactivity": "h5pactivity/pluginname", "core.mod_ims": "imscp/pluginname", "core.mod_imscp": "imscp/pluginname", "core.mod_label": "label/pluginname", diff --git a/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html b/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html index da77d63c7..23df4f6ad 100644 --- a/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html +++ b/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html @@ -5,7 +5,8 @@ - + + @@ -16,6 +17,11 @@ + + + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + {{ 'core.h5p.offlinedisabled' | translate }} {{ 'addon.mod_h5pactivity.offlinedisabledwarning' | translate }} diff --git a/src/addon/mod/h5pactivity/components/index/index.ts b/src/addon/mod/h5pactivity/components/index/index.ts index 36167eb04..0244d338f 100644 --- a/src/addon/mod/h5pactivity/components/index/index.ts +++ b/src/addon/mod/h5pactivity/components/index/index.ts @@ -25,12 +25,14 @@ import { CoreH5P } from '@core/h5p/providers/h5p'; import { CoreH5PDisplayOptions } from '@core/h5p/classes/core'; import { CoreH5PHelper } from '@core/h5p/classes/helper'; import { CoreXAPI } from '@core/xapi/providers/xapi'; +import { CoreXAPIOffline } from '@core/xapi/providers/offline'; import { CoreConstants } from '@core/constants'; import { CoreSite } from '@classes/site'; import { AddonModH5PActivity, AddonModH5PActivityProvider, AddonModH5PActivityData, AddonModH5PActivityAccessInfo } from '../../providers/h5pactivity'; +import { AddonModH5PActivitySyncProvider, AddonModH5PActivitySync } from '../../providers/sync'; /** * Component that displays an H5P activity entry page. @@ -59,8 +61,11 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv state: string; // State of the file. siteCanDownload: boolean; trackComponent: string; // Component for tracking. + hasOffline: boolean; + isOpeningPage: boolean; protected fetchContentDefaultError = 'addon.mod_h5pactivity.errorgetactivity'; + protected syncEventName = AddonModH5PActivitySyncProvider.AUTO_SYNCED; protected site: CoreSite; protected observer; protected messageListenerFunction: (event: MessageEvent) => Promise; @@ -103,13 +108,18 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv */ protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { try { - this.h5pActivity = await AddonModH5PActivity.instance.getH5PActivity(this.courseId, this.module.id); + this.h5pActivity = await AddonModH5PActivity.instance.getH5PActivity(this.courseId, this.module.id, false, this.siteId); this.dataRetrieved.emit(this.h5pActivity); this.description = this.h5pActivity.intro; this.displayOptions = CoreH5PHelper.decodeDisplayOptions(this.h5pActivity.displayoptions); + if (sync) { + await this.syncActivity(showErrors); + } + await Promise.all([ + this.checkHasOffline(), this.fetchAccessInfo(), this.fetchDeployedFileData(), ]); @@ -136,13 +146,22 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv } } + /** + * Fetch the access info and store it in the right variables. + * + * @return Promise resolved when done. + */ + protected async checkHasOffline(): Promise { + this.hasOffline = await CoreXAPIOffline.instance.contextHasStatements(this.h5pActivity.context, this.siteId); + } + /** * Fetch the access info and store it in the right variables. * * @return Promise resolved when done. */ protected async fetchAccessInfo(): Promise { - this.accessInfo = await AddonModH5PActivity.instance.getAccessInformation(this.h5pActivity.id); + this.accessInfo = await AddonModH5PActivity.instance.getAccessInformation(this.h5pActivity.id, false, this.siteId); } /** @@ -331,8 +350,17 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv /** * Go to view user events. */ - viewMyAttempts(): void { - this.navCtrl.push('AddonModH5PActivityUserAttemptsPage', {courseId: this.courseId, h5pActivityId: this.h5pActivity.id}); + async viewMyAttempts(): Promise { + this.isOpeningPage = true; + + try { + await this.navCtrl.push('AddonModH5PActivityUserAttemptsPage', { + courseId: this.courseId, + h5pActivityId: this.h5pActivity.id, + }); + } finally { + this.isOpeningPage = false; + } } /** @@ -342,12 +370,31 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv * @return Promise resolved when done. */ protected async onIframeMessage(event: MessageEvent): Promise { - if (!event.data || !CoreXAPI.instance.canPostStatementInSite(this.site) || !this.isCurrentXAPIPost(event.data)) { + if (!event.data || !CoreXAPI.instance.canPostStatementsInSite(this.site) || !this.isCurrentXAPIPost(event.data)) { return; } try { - await CoreXAPI.instance.postStatement(event.data.component, JSON.stringify(event.data.statements)); + const options = { + offline: this.hasOffline, + courseId: this.courseId, + extra: this.h5pActivity.name, + siteId: this.site.getId(), + }; + + const sent = await CoreXAPI.instance.postStatements(this.h5pActivity.context, event.data.component, + JSON.stringify(event.data.statements), options); + + this.hasOffline = !sent; + + if (sent) { + try { + // Invalidate attempts. + await AddonModH5PActivity.instance.invalidateUserAttempts(this.h5pActivity.id, undefined, this.siteId); + } catch (error) { + // Ignore errors. + } + } } catch (error) { CoreDomUtils.instance.showErrorModalDefault(error, 'Error sending tracking data.'); } @@ -380,6 +427,39 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv return match && match[1] == this.h5pActivity.context; } + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModH5PActivitySync.instance.syncActivity(this.h5pActivity.context, this.site.getId()); + } + + /** + * An autosync event has been received. + * + * @param syncEventData Data receiven on sync observer. + */ + protected autoSyncEventReceived(syncEventData: any): void { + this.checkHasOffline(); + } + + /** + * Go to blog posts. + * + * @param event Event. + */ + async gotoBlog(event: any): Promise { + this.isOpeningPage = true; + + try { + await super.gotoBlog(event); + } finally { + this.isOpeningPage = false; + } + } + /** * Component destroyed. */ diff --git a/src/addon/mod/h5pactivity/h5pactivity.module.ts b/src/addon/mod/h5pactivity/h5pactivity.module.ts index 3053c1168..85cce71a2 100644 --- a/src/addon/mod/h5pactivity/h5pactivity.module.ts +++ b/src/addon/mod/h5pactivity/h5pactivity.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; @@ -21,13 +22,16 @@ import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module- import { AddonModH5PActivityComponentsModule } from './components/components.module'; import { AddonModH5PActivityModuleHandler } from './providers/module-handler'; import { AddonModH5PActivityProvider } from './providers/h5pactivity'; +import { AddonModH5PActivitySyncProvider } from './providers/sync'; import { AddonModH5PActivityPrefetchHandler } from './providers/prefetch-handler'; import { AddonModH5PActivityIndexLinkHandler } from './providers/index-link-handler'; import { AddonModH5PActivityReportLinkHandler } from './providers/report-link-handler'; +import { AddonModH5PActivitySyncCronHandler } from './providers/sync-cron-handler'; // List of providers (without handlers). export const ADDON_MOD_H5P_ACTIVITY_PROVIDERS: any[] = [ AddonModH5PActivityProvider, + AddonModH5PActivitySyncProvider, ]; @NgModule({ @@ -38,10 +42,12 @@ export const ADDON_MOD_H5P_ACTIVITY_PROVIDERS: any[] = [ ], providers: [ AddonModH5PActivityProvider, + AddonModH5PActivitySyncProvider, AddonModH5PActivityModuleHandler, AddonModH5PActivityPrefetchHandler, AddonModH5PActivityIndexLinkHandler, AddonModH5PActivityReportLinkHandler, + AddonModH5PActivitySyncCronHandler, ] }) export class AddonModH5PActivityModule { @@ -51,11 +57,14 @@ export class AddonModH5PActivityModule { prefetchHandler: AddonModH5PActivityPrefetchHandler, linksDelegate: CoreContentLinksDelegate, indexHandler: AddonModH5PActivityIndexLinkHandler, - reportLinkHandler: AddonModH5PActivityReportLinkHandler) { + reportLinkHandler: AddonModH5PActivityReportLinkHandler, + cronDelegate: CoreCronDelegate, + syncHandler: AddonModH5PActivitySyncCronHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); linksDelegate.registerHandler(indexHandler); linksDelegate.registerHandler(reportLinkHandler); + cronDelegate.register(syncHandler); } } diff --git a/src/addon/mod/h5pactivity/pages/index/index.ts b/src/addon/mod/h5pactivity/pages/index/index.ts index 51a21a82f..e3bc7e304 100644 --- a/src/addon/mod/h5pactivity/pages/index/index.ts +++ b/src/addon/mod/h5pactivity/pages/index/index.ts @@ -56,7 +56,7 @@ export class AddonModH5PActivityIndexPage { * @return Resolved if we can leave it, rejected if not. */ ionViewCanLeave(): Promise { - if (!this.h5pComponent.playing) { + if (!this.h5pComponent.playing || this.h5pComponent.isOpeningPage) { return; } diff --git a/src/addon/mod/h5pactivity/providers/h5pactivity.ts b/src/addon/mod/h5pactivity/providers/h5pactivity.ts index ef7ccab9e..5e1fabc42 100644 --- a/src/addon/mod/h5pactivity/providers/h5pactivity.ts +++ b/src/addon/mod/h5pactivity/providers/h5pactivity.ts @@ -385,6 +385,20 @@ export class AddonModH5PActivityProvider { return this.getH5PActivityByField(courseId, 'coursemodule', cmId, forceCache, siteId); } + /** + * Get an H5P activity by context ID. + * + * @param courseId Course ID. + * @param contextId Context ID. + * @param forceCache Whether it should always return cached data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the activity data. + */ + getH5PActivityByContextId(courseId: number, contextId: number, forceCache?: boolean, siteId?: string) + : Promise { + return this.getH5PActivityByField(courseId, 'context', contextId, forceCache, siteId); + } + /** * Get an H5P activity by instance ID. * diff --git a/src/addon/mod/h5pactivity/providers/sync-cron-handler.ts b/src/addon/mod/h5pactivity/providers/sync-cron-handler.ts new file mode 100644 index 000000000..5ffccb86b --- /dev/null +++ b/src/addon/mod/h5pactivity/providers/sync-cron-handler.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@providers/cron'; +import { AddonModH5PActivitySync } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModH5PActivitySyncCronHandler implements CoreCronHandler { + name = 'AddonModH5PActivitySyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModH5PActivitySync.instance.syncAllActivities(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModH5PActivitySync.instance.syncInterval; + } +} diff --git a/src/addon/mod/h5pactivity/providers/sync.ts b/src/addon/mod/h5pactivity/providers/sync.ts new file mode 100644 index 000000000..dafa44842 --- /dev/null +++ b/src/addon/mod/h5pactivity/providers/sync.ts @@ -0,0 +1,223 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEvents } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreUtils } from '@providers/utils/utils'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreCourse } from '@core/course/providers/course'; +import { CoreCourseLogHelper } from '@core/course/providers/log-helper'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreCourseActivitySyncBaseProvider } from '@core/course/classes/activity-sync'; +import { CoreXAPI } from '@core/xapi/providers/xapi'; +import { CoreXAPIOffline } from '@core/xapi/providers/offline'; +import { AddonModH5PActivity, AddonModH5PActivityProvider } from './h5pactivity'; +import { AddonModH5PActivityPrefetchHandler } from './prefetch-handler'; + +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Service to sync H5P activities. + */ +@Injectable() +export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_h5pactivity_autom_synced'; + protected componentTranslate: string; + + constructor(sitesProvider: CoreSitesProvider, + loggerProvider: CoreLoggerProvider, + appProvider: CoreAppProvider, + translate: TranslateService, + textUtils: CoreTextUtilsProvider, + syncProvider: CoreSyncProvider, + timeUtils: CoreTimeUtilsProvider, + prefetchHandler: AddonModH5PActivityPrefetchHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate) { + + super('AddonModH5PActivitySyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, + timeUtils, prefetchDelegate, prefetchHandler); + + this.componentTranslate = CoreCourse.instance.translateModuleName('h5pactivity'); + } + + /** + * Try to synchronize all the H5P activities in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllActivities(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('H5P activities', this.syncAllActivitiesFunc.bind(this), [force], siteId); + } + + /** + * Sync all H5P activities on a site. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllActivitiesFunc(siteId?: string, force?: boolean): Promise { + const entries = await CoreXAPIOffline.instance.getAllStatements(siteId); + + // Sync all responses. + const promises = entries.map((response) => { + const promise = force ? this.syncActivity(response.contextid, siteId) : + this.syncActivityIfNeeded(response.contextid, siteId); + + return promise.then((result) => { + if (result && result.updated) { + // Sync successful, send event. + CoreEvents.instance.trigger(AddonModH5PActivitySyncProvider.AUTO_SYNCED, { + contextId: response.contextid, + warnings: result.warnings, + }, siteId); + } + }); + }); + + await Promise.all(promises); + } + + /** + * Sync an H5P activity only if a certain time has passed since the last time. + * + * @param contextId Context ID of the activity. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the activity is synced or it doesn't need to be synced. + */ + async syncActivityIfNeeded(contextId: number, siteId?: string): Promise { + const needed = await this.isSyncNeeded(contextId, siteId); + + if (needed) { + return this.syncActivity(contextId, siteId); + } + } + + /** + * Synchronize an H5P activity. If it's already being synced it will reuse the same promise. + * + * @param contextId Context ID of the activity. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncActivity(contextId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + throw this.translate.instant('core.networkerrormsg'); + } + + if (this.isSyncing(contextId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(contextId, siteId); + } + + return this.addOngoingSync(contextId, this.syncActivityData(contextId, siteId), siteId); + } + + /** + * Synchronize an H5P activity. + * + * @param contextId Context ID of the activity. + * @param siteId Site ID. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async syncActivityData(contextId: number, siteId: string): Promise<{warnings: string[], updated: boolean}> { + + this.logger.debug(`Try to sync H5P activity with context ID '${contextId}'`); + + const result = { + warnings: [], + updated: false, + }; + + // Get all the statements stored for the activity. + const entries = await CoreXAPIOffline.instance.getContextStatements(contextId, siteId); + + if (!entries || !entries.length) { + // Nothing to sync. + await this.setSyncTime(contextId, siteId); + + return result; + } + + // Get the activity instance. + const courseId = entries[0].courseid; + + const h5pActivity = await AddonModH5PActivity.instance.getH5PActivityByContextId(courseId, contextId, false, siteId); + + // Sync offline logs. + try { + await CoreCourseLogHelper.instance.syncIfNeeded(AddonModH5PActivityProvider.COMPONENT, h5pActivity.id, siteId); + } catch (error) { + // Ignore errors. + } + + // Send the statements in order. + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + try { + await CoreXAPI.instance.postStatementsOnline(entry.component, entry.statements, siteId); + + result.updated = true; + + await CoreXAPIOffline.instance.deleteStatements(entry.id, siteId); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that statements cannot be submitted. Delete them. + result.updated = true; + + await CoreXAPIOffline.instance.deleteStatements(entry.id, siteId); + + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: entry.extra, + error: this.textUtils.getErrorMessageFromError(error), + })); + } else { + // Stop synchronizing. + throw error; + } + } + } + + if (result.updated) { + try { + // Data has been sent to server, invalidate attempts. + await AddonModH5PActivity.instance.invalidateUserAttempts(h5pActivity.id, undefined, siteId); + } catch (error) { + // Ignore errors. + } + } + + // Sync finished, set sync time. + await this.setSyncTime(contextId, siteId); + + return result; + } +} + +export class AddonModH5PActivitySync extends makeSingleton(AddonModH5PActivitySyncProvider) {} diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 4682e88d7..1f54d6380 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1862,6 +1862,7 @@ "core.mod_folder": "Folder", "core.mod_forum": "Forum", "core.mod_glossary": "Glossary", + "core.mod_h5pactivity": "H5P", "core.mod_ims": "IMS content package", "core.mod_imscp": "IMS content package", "core.mod_label": "Label", diff --git a/src/core/compile/providers/compile.ts b/src/core/compile/providers/compile.ts index 0c9b84f9a..85d063b14 100644 --- a/src/core/compile/providers/compile.ts +++ b/src/core/compile/providers/compile.ts @@ -41,6 +41,7 @@ import { CORE_PUSHNOTIFICATIONS_PROVIDERS } from '@core/pushnotifications/pushno import { IONIC_NATIVE_PROVIDERS } from '@core/emulator/emulator.module'; import { CORE_EDITOR_PROVIDERS } from '@core/editor/editor.module'; import { CORE_SEARCH_PROVIDERS } from '@core/search/search.module'; +import { CORE_XAPI_PROVIDERS } from '@core/xapi/xapi.module'; // Import only this provider to prevent circular dependencies. import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; @@ -243,7 +244,7 @@ export class CoreCompileProvider { .concat(ADDON_MOD_WORKSHOP_PROVIDERS).concat(ADDON_NOTES_PROVIDERS).concat(ADDON_NOTIFICATIONS_PROVIDERS) .concat(CORE_PUSHNOTIFICATIONS_PROVIDERS).concat(ADDON_REMOTETHEMES_PROVIDERS).concat(CORE_BLOCK_PROVIDERS) .concat(CORE_FILTER_PROVIDERS).concat(CORE_H5P_PROVIDERS).concat(CORE_EDITOR_PROVIDERS) - .concat(CORE_SEARCH_PROVIDERS).concat(ADDON_MOD_H5P_ACTIVITY_PROVIDERS); + .concat(CORE_SEARCH_PROVIDERS).concat(ADDON_MOD_H5P_ACTIVITY_PROVIDERS).concat(CORE_XAPI_PROVIDERS); // We cannot inject anything to this constructor. Use the Injector to inject all the providers into the instance. for (const i in providers) { diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts index 0a333639b..67a5985d3 100644 --- a/src/core/course/classes/main-resource-component.ts +++ b/src/core/course/classes/main-resource-component.ts @@ -280,8 +280,8 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, * * @param event Event. */ - gotoBlog(event: any): void { - this.linkHelper.goInSite(this.navCtrl, 'AddonBlogEntriesPage', { cmId: this.module.id }); + gotoBlog(event: any): Promise { + return this.linkHelper.goInSite(this.navCtrl, 'AddonBlogEntriesPage', { cmId: this.module.id }); } /** diff --git a/src/core/xapi/providers/offline.ts b/src/core/xapi/providers/offline.ts new file mode 100644 index 000000000..26005962c --- /dev/null +++ b/src/core/xapi/providers/offline.ts @@ -0,0 +1,201 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSites, CoreSiteSchema } from '@providers/sites'; + +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Service to handle offline xAPI. + */ +@Injectable() +export class CoreXAPIOfflineProvider { + + // Variables for database. + static STATEMENTS_TABLE = 'core_xapi_statements'; + + protected siteSchema: CoreSiteSchema = { + name: 'CoreXAPIOfflineProvider', + version: 1, + tables: [ + { + name: CoreXAPIOfflineProvider.STATEMENTS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'contextid', + type: 'INTEGER' + }, + { + name: 'component', + type: 'TEXT' + }, + { + name: 'statements', + type: 'TEXT' + }, + { + name: 'timecreated', + type: 'INTEGER' + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'extra', + type: 'TEXT' + }, + ], + } + ] + }; + + constructor() { + CoreSites.instance.registerSiteSchema(this.siteSchema); + } + + /** + * Check if there are offline statements to send for a context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline statements, false otherwise. + */ + async contextHasStatements(contextId: number, siteId?: string): Promise { + const statementsList = await this.getContextStatements(contextId, siteId); + + return statementsList && statementsList.length > 0; + } + + /** + * Delete certain statements. + * + * @param id ID of the statements. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteStatements(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(CoreXAPIOfflineProvider.STATEMENTS_TABLE, {id}); + } + + /** + * Delete all statements of a certain context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteStatementsForContext(contextId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(CoreXAPIOfflineProvider.STATEMENTS_TABLE, {contextid: contextId}); + } + + /** + * Get all offline statements. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with all the data. + */ + async getAllStatements(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecords(CoreXAPIOfflineProvider.STATEMENTS_TABLE, undefined, 'timecreated ASC'); + } + + /** + * Get statements for a context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the data. + */ + async getContextStatements(contextId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecords(CoreXAPIOfflineProvider.STATEMENTS_TABLE, {contextid: contextId}, 'timecreated ASC'); + } + + /** + * Get certain statements. + * + * @param id ID of the statements. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the data. + */ + async getStatements(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(CoreXAPIOfflineProvider.STATEMENTS_TABLE, {id}); + } + + /** + * Save statements. + * + * @param contextId Context ID. + * @param component Component to send the statements to. + * @param statements Statements (JSON-encoded). + * @param options Options. + * @return Promise resolved when statements are successfully saved. + */ + async saveStatements(contextId: number, component: string, statements: string, options?: CoreXAPIOfflineSaveStatementsOptions) + : Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const entry = { + contextid: contextId, + component: component, + statements: statements, + timecreated: Date.now(), + courseid: options.courseId, + extra: options.extra, + }; + + await site.getDb().insertRecord(CoreXAPIOfflineProvider.STATEMENTS_TABLE, entry); + } +} + +export class CoreXAPIOffline extends makeSingleton(CoreXAPIOfflineProvider) {} + +/** + * DB data stored for statements. + */ +export type CoreXAPIOfflineStatementsDBData = { + id: number; // ID. + contextid: number; // Context ID of the statements. + component: string; // Component to send the statements to. + statements: string; // Statements (JSON-encoded). + timecreated: number; // When were the statements created. + courseid?: number; // Course ID if the context is inside a course. + extra?: string; // Extra data. +}; + +/** + * Options to pass to saveStatements function. + */ +export type CoreXAPIOfflineSaveStatementsOptions = { + 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. +}; diff --git a/src/core/xapi/providers/xapi.ts b/src/core/xapi/providers/xapi.ts index e9cccc985..997d02763 100644 --- a/src/core/xapi/providers/xapi.ts +++ b/src/core/xapi/providers/xapi.ts @@ -13,9 +13,12 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreApp } from '@providers/app'; import { CoreSites } from '@providers/sites'; import { CoreTextUtils } from '@providers/utils/text'; +import { CoreUtils } from '@providers/utils/utils'; import { CoreSite } from '@classes/site'; +import { CoreXAPIOffline, CoreXAPIOfflineSaveStatementsOptions } from './offline'; import { makeSingleton } from '@singletons/core.singletons'; @@ -34,10 +37,10 @@ export class CoreXAPIProvider { * @return Promise resolved with true if ws is available, false otherwise. * @since 3.9 */ - async canPostStatement(siteId?: string): Promise { + async canPostStatements(siteId?: string): Promise { const site = await CoreSites.instance.getSite(siteId); - return this.canPostStatementInSite(site); + return this.canPostStatementsInSite(site); } /** @@ -47,7 +50,7 @@ export class CoreXAPIProvider { * @return Promise resolved with true if ws is available, false otherwise. * @since 3.9 */ - canPostStatementInSite(site?: CoreSite): boolean { + canPostStatementsInSite(site?: CoreSite): boolean { site = site || CoreSites.instance.getCurrentSite(); return site.wsAvailable('core_xapi_statement_post'); @@ -68,14 +71,56 @@ export class CoreXAPIProvider { } /** - * Post an statement. + * Post statements. + * + * @param contextId Context ID. + * @param component Component. + * @param json JSON string to send. + * @param options Options. + * @return Promise resolved with boolean: true if response was sent to server, false if stored in device. + */ + async postStatements(contextId: number, component: string, json: string, options?: CoreXAPIPostStatementsOptions) + : Promise { + + options = options || {}; + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await CoreXAPIOffline.instance.saveStatements(contextId, component, json, options); + + return false; + }; + + if (!CoreApp.instance.isOnline() || options.offline) { + // App is offline, store the action. + return storeOffline(); + } + + try { + await this.postStatementsOnline(component, json, options.siteId); + + return true; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } else { + // Couldn't connect to server, store it offline. + return storeOffline(); + } + } + } + + /** + * Post statements. It will fail if offline or cannot connect. * * @param component Component. * @param json JSON string to send. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - async postStatement(component: string, json: string, siteId?: string): Promise { + async postStatementsOnline(component: string, json: string, siteId?: string): Promise { const site = await CoreSites.instance.getSite(siteId); @@ -89,3 +134,10 @@ export class CoreXAPIProvider { } export class CoreXAPI extends makeSingleton(CoreXAPIProvider) {} + +/** + * Options to pass to postStatements function. + */ +export type CoreXAPIPostStatementsOptions = CoreXAPIOfflineSaveStatementsOptions & { + offline?: boolean; // Whether to force storing it in offline. +}; diff --git a/src/core/xapi/xapi.module.ts b/src/core/xapi/xapi.module.ts index 0d3a9ee2f..7a955bffe 100644 --- a/src/core/xapi/xapi.module.ts +++ b/src/core/xapi/xapi.module.ts @@ -14,10 +14,12 @@ import { NgModule } from '@angular/core'; import { CoreXAPIProvider } from './providers/xapi'; +import { CoreXAPIOfflineProvider } from './providers/offline'; // List of providers (without handlers). export const CORE_XAPI_PROVIDERS: any[] = [ CoreXAPIProvider, + CoreXAPIOfflineProvider, ]; @NgModule({ @@ -25,6 +27,7 @@ export const CORE_XAPI_PROVIDERS: any[] = [ imports: [], providers: [ CoreXAPIProvider, + CoreXAPIOfflineProvider, ], exports: [] }) diff --git a/src/lang/en.json b/src/lang/en.json index d925a1bea..5048707e4 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -154,6 +154,7 @@ "mod_folder": "Folder", "mod_forum": "Forum", "mod_glossary": "Glossary", + "mod_h5pactivity": "H5P", "mod_ims": "IMS content package", "mod_imscp": "IMS content package", "mod_label": "Label",