MOBILE-3412: Support offline and sync

main
Dani Palou 2020-06-08 15:45:44 +02:00
parent 90dfe5a891
commit 159056a6fb
15 changed files with 656 additions and 17 deletions

View File

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

View File

@ -5,7 +5,8 @@
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate" [href]="externalUrl" [iconAction]="'open'"></core-context-menu-item>
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate" (action)="expandDescription()" [iconAction]="'arrow-forward'"></core-context-menu-item>
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" [iconAction]="'fa-newspaper-o'" (action)="gotoBlog($event)"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && isOnline" [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.removefiles' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
</core-context-menu>
@ -16,6 +17,11 @@
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId"></core-course-module-description>
<!-- Offline data stored. -->
<ion-card class="core-warning-card" icon-start *ngIf="hasOffline">
<ion-icon name="warning"></ion-icon> {{ 'core.hasdatatosync' | translate:{$a: moduleName} }}
</ion-card>
<!-- Offline disabled. -->
<ion-card class="core-warning-card" icon-start *ngIf="!siteCanDownload && playing">
<ion-icon name="warning"></ion-icon> {{ 'core.h5p.offlinedisabled' | translate }} {{ 'addon.mod_h5pactivity.offlinedisabledwarning' | translate }}

View File

@ -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<void>;
@ -103,13 +108,18 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
*/
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<any> {
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<void> {
this.isOpeningPage = true;
try {
await super.gotoBlog(event);
} finally {
this.isOpeningPage = false;
}
}
/**
* Component destroyed.
*/

View File

@ -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);
}
}

View File

@ -56,7 +56,7 @@ export class AddonModH5PActivityIndexPage {
* @return Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): Promise<void> {
if (!this.h5pComponent.playing) {
if (!this.h5pComponent.playing || this.h5pComponent.isOpeningPage) {
return;
}

View File

@ -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<AddonModH5PActivityData> {
return this.getH5PActivityByField(courseId, 'context', contextId, forceCache, siteId);
}
/**
* Get an H5P activity by instance ID.
*

View File

@ -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<any> {
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;
}
}

View File

@ -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<void> {
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<void> {
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<any> {
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<any> {
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) {}

View File

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

View File

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

View File

@ -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<any> {
return this.linkHelper.goInSite(this.navCtrl, 'AddonBlogEntriesPage', { cmId: this.module.id });
}
/**

View File

@ -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<boolean> {
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<void> {
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<void> {
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<CoreXAPIOfflineStatementsDBData[]> {
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<CoreXAPIOfflineStatementsDBData[]> {
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<CoreXAPIOfflineStatementsDBData> {
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<void> {
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.
};

View File

@ -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<boolean> {
async canPostStatements(siteId?: string): Promise<boolean> {
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<boolean> {
options = options || {};
options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
// Convenience function to store a message to be synchronized later.
const storeOffline = async (): Promise<boolean> => {
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<number[]> {
async postStatementsOnline(component: string, json: string, siteId?: string): Promise<number[]> {
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.
};

View File

@ -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: []
})

View File

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