diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.html b/src/addon/mod/lesson/pages/password-modal/password-modal.html
new file mode 100644
index 000000000..dd55a244a
--- /dev/null
+++ b/src/addon/mod/lesson/pages/password-modal/password-modal.html
@@ -0,0 +1,26 @@
+
+
+ {{ 'core.login.password' | translate }}
+
+
+
+
+
+
+
+
diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts b/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts
new file mode 100644
index 000000000..5aebe2d78
--- /dev/null
+++ b/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts
@@ -0,0 +1,31 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { NgModule } from '@angular/core';
+import { IonicPageModule } from 'ionic-angular';
+import { AddonModLessonPasswordModalPage } from './password-modal';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreComponentsModule } from '@components/components.module';
+
+@NgModule({
+ declarations: [
+ AddonModLessonPasswordModalPage
+ ],
+ imports: [
+ CoreComponentsModule,
+ IonicPageModule.forChild(AddonModLessonPasswordModalPage),
+ TranslateModule.forChild()
+ ]
+})
+export class AddonModLessonPasswordModalPageModule {}
diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.ts b/src/addon/mod/lesson/pages/password-modal/password-modal.ts
new file mode 100644
index 000000000..d9c116c8f
--- /dev/null
+++ b/src/addon/mod/lesson/pages/password-modal/password-modal.ts
@@ -0,0 +1,43 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { Component } from '@angular/core';
+import { IonicPage, ViewController } from 'ionic-angular';
+
+/**
+ * Modal that asks the password for a lesson.
+ */
+@IonicPage({ segment: 'addon-mod-lesson-password-modal' })
+@Component({
+ selector: 'page-addon-mod-lesson-password-modal',
+ templateUrl: 'password-modal.html',
+})
+export class AddonModLessonPasswordModalPage {
+
+ constructor(protected viewCtrl: ViewController) { }
+
+ /**
+ * Send the password back.
+ */
+ submitPassword(password: HTMLInputElement): void {
+ this.viewCtrl.dismiss(password.value);
+ }
+
+ /**
+ * Close modal.
+ */
+ closeModal(): void {
+ this.viewCtrl.dismiss();
+ }
+}
diff --git a/src/addon/mod/lesson/providers/lesson-sync.ts b/src/addon/mod/lesson/providers/lesson-sync.ts
new file mode 100644
index 000000000..e2cd2841c
--- /dev/null
+++ b/src/addon/mod/lesson/providers/lesson-sync.ts
@@ -0,0 +1,498 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { CoreEventsProvider } from '@providers/events';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreSitesProvider } from '@providers/sites';
+import { CoreSyncProvider } from '@providers/sync';
+import { CoreTextUtilsProvider } from '@providers/utils/text';
+import { CoreTimeUtilsProvider } from '@providers/utils/time';
+import { CoreUrlUtilsProvider } from '@providers/utils/url';
+import { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreCourseProvider } from '@core/course/providers/course';
+import { CoreSyncBaseProvider } from '@classes/base-sync';
+import { AddonModLessonProvider } from './lesson';
+import { AddonModLessonOfflineProvider } from './lesson-offline';
+import { AddonModLessonPrefetchHandler } from './prefetch-handler';
+
+/**
+ * Data returned by a lesson sync.
+ */
+export interface AddonModLessonSyncResult {
+ /**
+ * List of warnings.
+ * @type {string[]}
+ */
+ warnings: string[];
+
+ /**
+ * Whether some data was sent to the server or offline data was updated.
+ * @type {boolean}
+ */
+ updated: boolean;
+}
+
+/**
+ * Service to sync lesson.
+ */
+@Injectable()
+export class AddonModLessonSyncProvider extends CoreSyncBaseProvider {
+
+ static AUTO_SYNCED = 'addon_mod_lesson_autom_synced';
+ static SYNC_TIME = 300000;
+
+ protected componentTranslate: string;
+
+ // Variables for database.
+ protected RETAKES_FINISHED_TABLE = 'addon_mod_lesson_retakes_finished_sync';
+ protected tablesSchema = {
+ name: this.RETAKES_FINISHED_TABLE,
+ columns: [
+ {
+ name: 'lessonId',
+ type: 'INTEGER',
+ primaryKey: true
+ },
+ {
+ name: 'retake',
+ type: 'INTEGER'
+ },
+ {
+ name: 'pageId',
+ type: 'INTEGER'
+ },
+ {
+ name: 'timefinished',
+ type: 'INTEGER'
+ }
+ ]
+ };
+
+ constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider,
+ syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService,
+ courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider,
+ private lessonProvider: AddonModLessonProvider, private lessonOfflineProvider: AddonModLessonOfflineProvider,
+ private prefetchHandler: AddonModLessonPrefetchHandler, private timeUtils: CoreTimeUtilsProvider,
+ private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider) {
+
+ super('AddonModLessonSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate);
+
+ this.componentTranslate = courseProvider.translateModuleName('lesson');
+
+ this.sitesProvider.createTableFromSchema(this.tablesSchema);
+ }
+
+ /**
+ * Unmark a retake as finished in a synchronization.
+ *
+ * @param {number} lessonId Lesson ID.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved when done.
+ */
+ deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise {
+ return this.sitesProvider.getSite(siteId).then((site) => {
+ return site.getDb().deleteRecords(this.RETAKES_FINISHED_TABLE, {lessonId});
+ }).catch(() => {
+ // Ignore errors, maybe there is none.
+ });
+ }
+
+ /**
+ * Get a retake finished in a synchronization for a certain lesson (if any).
+ *
+ * @param {number} lessonId Lesson ID.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved with the retake entry (undefined if no retake).
+ */
+ getRetakeFinishedInSync(lessonId: number, siteId?: string): Promise {
+ return this.sitesProvider.getSite(siteId).then((site) => {
+ return site.getDb().getRecord(this.RETAKES_FINISHED_TABLE, {lessonId});
+ }).catch(() => {
+ // Ignore errors, return undefined.
+ });
+ }
+
+ /**
+ * Check if a lesson has data to synchronize.
+ *
+ * @param {number} lessonId Lesson ID.
+ * @param {number} retake Retake number.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved with boolean: whether it has data to sync.
+ */
+ hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise {
+ const promises = [];
+ let hasDataToSync = false;
+
+ promises.push(this.lessonOfflineProvider.hasRetakeAttempts(lessonId, retake, siteId).then((hasAttempts) => {
+ hasDataToSync = hasDataToSync || hasAttempts;
+ }).catch(() => {
+ // Ignore errors.
+ }));
+
+ promises.push(this.lessonOfflineProvider.hasFinishedRetake(lessonId, siteId).then((hasFinished) => {
+ hasDataToSync = hasDataToSync || hasFinished;
+ }));
+
+ return Promise.all(promises).then(() => {
+ return hasDataToSync;
+ });
+ }
+
+ /**
+ * Mark a retake as finished in a synchronization.
+ *
+ * @param {number} lessonId Lesson ID.
+ * @param {number} retake The retake number.
+ * @param {number} pageId The page ID to start reviewing from.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved when done.
+ */
+ setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise {
+ return this.sitesProvider.getSite(siteId).then((site) => {
+ return site.getDb().insertRecord(this.RETAKES_FINISHED_TABLE, {
+ lessonId: lessonId,
+ retake: Number(retake),
+ pageId: Number(pageId),
+ timefinished: this.timeUtils.timestamp()
+ });
+ });
+ }
+
+ /**
+ * Try to synchronize all the lessons in a certain site or in all sites.
+ *
+ * @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
+ * @return {Promise} Promise resolved if sync is successful, rejected if sync fails.
+ */
+ syncAllLessons(siteId?: string): Promise {
+ return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this), [], siteId);
+ }
+
+ /**
+ * Sync all lessons on a site.
+ *
+ * @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
+ * @param {Promise} Promise resolved if sync is successful, rejected if sync fails.
+ */
+ protected syncAllLessonsFunc(siteId?: string): Promise {
+ // Get all the lessons that have something to be synchronized.
+ return this.lessonOfflineProvider.getAllLessonsWithData(siteId).then((lessons) => {
+ // Sync all lessons that haven't been synced for a while.
+ const promises = [];
+
+ lessons.forEach((lesson) => {
+ promises.push(this.syncLessonIfNeeded(lesson.id, false, siteId).then((result) => {
+ if (result && result.updated) {
+ // Sync successful, send event.
+ this.eventsProvider.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, {
+ lessonId: lesson.id,
+ warnings: result.warnings
+ }, siteId);
+ }
+ }));
+ });
+
+ return Promise.all(promises);
+ });
+ }
+
+ /**
+ * Sync a lesson only if a certain time has passed since the last time.
+ *
+ * @param {any} lessonId Lesson ID.
+ * @param {boolean} [askPreflight] Whether we should ask for password if needed.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved when the lesson is synced or if it doesn't need to be synced.
+ */
+ syncLessonIfNeeded(lessonId: number, askPassword?: boolean, siteId?: string): Promise {
+ return this.isSyncNeeded(lessonId, siteId).then((needed) => {
+ if (needed) {
+ return this.syncLesson(lessonId, askPassword, false, siteId);
+ }
+ });
+ }
+
+ /**
+ * Try to synchronize a lesson.
+ *
+ * @param {number} lessonId Lesson ID.
+ * @param {boolean} askPassword True if we should ask for password if needed, false otherwise.
+ * @param {boolean} ignoreBlock True to ignore the sync block setting.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved in success.
+ */
+ syncLesson(lessonId: number, askPassword?: boolean, ignoreBlock?: boolean, siteId?: string): Promise {
+ siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+ const result: AddonModLessonSyncResult = {
+ warnings: [],
+ updated: false
+ };
+ let syncPromise,
+ lesson,
+ courseId,
+ password,
+ accessInfo;
+
+ if (this.isSyncing(lessonId, siteId)) {
+ // There's already a sync ongoing for this lesson, return the promise.
+ return this.getOngoingSync(lessonId, siteId);
+ }
+
+ // Verify that lesson isn't blocked.
+ if (!ignoreBlock && this.syncProvider.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) {
+ this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.');
+
+ return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
+ }
+
+ this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId);
+
+ // Try to synchronize the attempts first.
+ syncPromise = this.lessonOfflineProvider.getLessonAttempts(lessonId, siteId).then((attempts) => {
+ if (!attempts.length) {
+ return;
+ } else if (!this.appProvider.isOnline()) {
+ // Cannot sync in offline.
+ return Promise.reject(null);
+ }
+
+ courseId = attempts[0].courseid;
+
+ // Get the info, access info and the lesson password if needed.
+ return this.lessonProvider.getLessonById(courseId, lessonId, false, siteId).then((lessonData) => {
+ lesson = lessonData;
+
+ return this.prefetchHandler.getLessonPassword(lessonId, false, true, askPassword, siteId);
+ }).then((data) => {
+ const attemptsLength = attempts.length,
+ promises = [];
+
+ accessInfo = data.accessInfo;
+ password = data.password;
+ lesson = data.lesson || lesson;
+
+ // Filter the attempts, get only the ones that belong to the current retake.
+ attempts = attempts.filter((attempt) => {
+ if (attempt.retake != accessInfo.attemptscount) {
+ // Attempt doesn't belong to current retake, delete.
+ promises.push(this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid,
+ attempt.timemodified, siteId).catch(() => {
+ // Ignore errors.
+ }));
+
+ return false;
+ }
+
+ return true;
+ });
+
+ if (attempts.length != attemptsLength) {
+ // Some attempts won't be sent, add a warning.
+ result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
+ component: this.componentTranslate,
+ name: lesson.name,
+ error: this.translate.instant('addon.mod_lesson.warningretakefinished')
+ }));
+ }
+
+ return Promise.all(promises);
+ }).then(() => {
+ if (!attempts.length) {
+ return;
+ }
+
+ // Send the attempts in the same order they were answered.
+ attempts.sort((a, b) => {
+ return a.timemodified - b.timemodified;
+ });
+
+ attempts = attempts.map((attempt) => {
+ return {
+ func: this.sendAttempt.bind(this),
+ params: [lesson, password, attempt, result, siteId],
+ blocking: true
+ };
+ });
+
+ return this.utils.executeOrderedPromises(attempts);
+ });
+ }).then(() => {
+ // Attempts sent or there was none. If there is a finished retake, send it.
+ return this.lessonOfflineProvider.getRetake(lessonId, siteId).then((retake) => {
+ if (!retake.finished) {
+ // The retake isn't marked as finished, nothing to send. Delete the retake.
+ return this.lessonOfflineProvider.deleteRetake(lessonId, siteId);
+ } else if (!this.appProvider.isOnline()) {
+ // Cannot sync in offline.
+ return Promise.reject(null);
+ }
+
+ let promise;
+
+ courseId = retake.courseid || courseId;
+
+ if (lesson) {
+ // Data already retrieved when syncing attempts.
+ promise = Promise.resolve();
+ } else {
+ promise = this.lessonProvider.getLessonById(courseId, lessonId, false, siteId).then((lessonData) => {
+ lesson = lessonData;
+
+ return this.prefetchHandler.getLessonPassword(lessonId, false, true, askPassword, siteId);
+ }).then((data) => {
+ accessInfo = data.accessInfo;
+ password = data.password;
+ lesson = data.lesson || lesson;
+ });
+ }
+
+ return promise.then(() => {
+ if (retake.retake != accessInfo.attemptscount) {
+ // The retake changed, add a warning if it isn't there already.
+ if (!result.warnings.length) {
+ result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
+ component: this.componentTranslate,
+ name: lesson.name,
+ error: this.translate.instant('addon.mod_lesson.warningretakefinished')
+ }));
+ }
+
+ return this.lessonOfflineProvider.deleteRetake(lessonId, siteId);
+ }
+
+ // All good, finish the retake.
+ return this.lessonProvider.finishRetakeOnline(lessonId, password, false, false, siteId).then((response) => {
+ result.updated = true;
+
+ if (!ignoreBlock) {
+ // Mark the retake as finished in a sync if it can be reviewed.
+ if (response.data && response.data.reviewlesson) {
+ const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value);
+ if (params && params.pageid) {
+ // The retake can be reviewed, mark it as finished. Don't block the user for this.
+ this.setRetakeFinishedInSync(lessonId, retake.retake, params.pageid, siteId);
+ }
+ }
+ }
+
+ return this.lessonOfflineProvider.deleteRetake(lessonId, siteId);
+ }).catch((error) => {
+ if (error && this.utils.isWebServiceError(error)) {
+ // The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
+ result.updated = true;
+
+ return this.lessonOfflineProvider.deleteRetake(lessonId, siteId).then(() => {
+ // Retake deleted, add a warning.
+ result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
+ component: this.componentTranslate,
+ name: lesson.name,
+ error: error
+ }));
+ });
+ } else {
+ // Couldn't connect to server, reject.
+ return Promise.reject(error);
+ }
+ });
+ });
+ }, () => {
+ // No retake stored, nothing to do.
+ });
+ }).then(() => {
+ if (result.updated && courseId) {
+ // Data has been sent to server. Now invalidate the WS calls.
+ const promises = [];
+
+ promises.push(this.lessonProvider.invalidateAccessInformation(lessonId, siteId));
+ promises.push(this.lessonProvider.invalidateContentPagesViewed(lessonId, siteId));
+ promises.push(this.lessonProvider.invalidateQuestionsAttempts(lessonId, siteId));
+ promises.push(this.lessonProvider.invalidatePagesPossibleJumps(lessonId, siteId));
+ promises.push(this.lessonProvider.invalidateTimers(lessonId, siteId));
+
+ return this.utils.allPromises(promises).catch(() => {
+ // Ignore errors.
+ }).then(() => {
+ // Sync successful, update some data that might have been modified.
+ return this.lessonProvider.getAccessInformation(lessonId, false, false, siteId).then((info) => {
+ const promises = [],
+ retake = info.attemptscount;
+
+ promises.push(this.lessonProvider.getContentPagesViewedOnline(lessonId, retake, false, false, siteId));
+ promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lessonId, retake, false, undefined, false,
+ false, siteId));
+
+ return Promise.all(promises);
+ }).catch(() => {
+ // Ignore errors.
+ });
+ });
+ }
+ }).then(() => {
+ // Sync finished, set sync time.
+ return this.setSyncTime(lessonId, siteId).catch(() => {
+ // Ignore errors.
+ });
+ }).then(() => {
+ // All done, return the result.
+ return result;
+ });
+
+ return this.addOngoingSync(lessonId, syncPromise, siteId);
+ }
+
+ /**
+ * Send an attempt to the site and delete it afterwards.
+ *
+ * @param {any} lesson Lesson.
+ * @param {string} password Password (if any).
+ * @param {any} attempt Attempt to send.
+ * @param {AddonModLessonSyncResult} result Result where to store the data.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved when done.
+ */
+ protected sendAttempt(lesson: any, password: string, attempt: any, result: AddonModLessonSyncResult, siteId?: string)
+ : Promise {
+
+ return this.lessonProvider.processPageOnline(lesson.id, attempt.pageid, attempt.data, password, false, siteId).then(() => {
+ result.updated = true;
+
+ return this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, attempt.timemodified,
+ siteId);
+ }).catch((error) => {
+ if (error && this.utils.isWebServiceError(error)) {
+ // The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it.
+ result.updated = true;
+
+ return this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, attempt.timemodified,
+ siteId).then(() => {
+
+ // Attempt deleted, add a warning.
+ result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
+ component: this.componentTranslate,
+ name: lesson.name,
+ error: error
+ }));
+ });
+ } else {
+ // Couldn't connect to server, reject.
+ return Promise.reject(error);
+ }
+ });
+ }
+}
diff --git a/src/addon/mod/lesson/providers/prefetch-handler.ts b/src/addon/mod/lesson/providers/prefetch-handler.ts
new file mode 100644
index 000000000..4a1dc83c9
--- /dev/null
+++ b/src/addon/mod/lesson/providers/prefetch-handler.ts
@@ -0,0 +1,449 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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, Injector } from '@angular/core';
+import { ModalController } from 'ionic-angular';
+import { CoreGroupsProvider } from '@providers/groups';
+import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler';
+import { AddonModLessonProvider } from './lesson';
+
+/**
+ * Handler to prefetch lessons.
+ */
+@Injectable()
+export class AddonModLessonPrefetchHandler extends CoreCourseModulePrefetchHandlerBase {
+ name = 'AddonModLesson';
+ modName = 'lesson';
+ component = AddonModLessonProvider.COMPONENT;
+ // Don't check timers to decrease positives. If a user performs some action it will be reflected in other items.
+ updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^pages$|^answers$|^questionattempts$|^pagesviewed$/;
+
+ constructor(protected injector: Injector, protected modalCtrl: ModalController, protected groupsProvider: CoreGroupsProvider,
+ protected lessonProvider: AddonModLessonProvider) {
+ super(injector);
+ }
+
+ /**
+ * Ask password.
+ *
+ * @param {any} info Lesson access info.
+ * @return {Promise} Promise resolved with the password.
+ */
+ protected askUserPassword(info: any): Promise {
+ // Create and show the modal.
+ const modal = this.modalCtrl.create('AddonModLessonPasswordModalPage');
+
+ modal.present();
+
+ // Wait for modal to be dismissed.
+ return new Promise((resolve, reject): void => {
+ modal.onDidDismiss((password) => {
+ if (typeof password != 'undefined') {
+ resolve(password);
+ } else {
+ reject(this.domUtils.createCanceledError());
+ }
+ });
+ });
+ }
+
+ /**
+ * Download the module.
+ *
+ * @param {any} module The module object returned by WS.
+ * @param {number} courseId Course ID.
+ * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
+ * @return {Promise} Promise resolved when all content is downloaded.
+ */
+ download(module: any, courseId: number, dirPath?: string): Promise {
+ // Same implementation for download and prefetch.
+ return this.prefetch(module, courseId, false, dirPath);
+ }
+
+ /**
+ * Get the download size of a module.
+ *
+ * @param {any} module Module.
+ * @param {Number} courseId Course ID the module belongs to.
+ * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
+ * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able
+ * to calculate the total size.
+ */
+ getDownloadSize(module: any, courseId: any, single?: boolean): Promise<{ size: number, total: boolean }> {
+ const siteId = this.sitesProvider.getCurrentSiteId();
+ let lesson,
+ password,
+ result;
+
+ return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lessonData) => {
+ lesson = lessonData;
+
+ // Get the lesson password if it's needed.
+ return this.getLessonPassword(lesson.id, false, true, single, siteId);
+ }).then((data) => {
+ password = data.password;
+ lesson = data.lesson || lesson;
+
+ // Get intro files and media files.
+ let files = lesson.mediafiles || [];
+ files = files.concat(this.getIntroFilesFromInstance(module, lesson));
+
+ result = this.utils.sumFileSizes(files);
+
+ // Get the pages to calculate the size.
+ return this.lessonProvider.getPages(lesson.id, password, false, false, siteId);
+ }).then((pages) => {
+ pages.forEach((page) => {
+ result.size += page.filessizetotal;
+ });
+
+ return result;
+ });
+ }
+
+ /**
+ * Get list of files. If not defined, we'll assume they're in module.contents.
+ *
+ * @param {any} module Module.
+ * @param {Number} courseId Course ID the module belongs to.
+ * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
+ * @return {Promise} Promise resolved with the list of files.
+ */
+ getFiles(module: any, courseId: number, single?: boolean): Promise {
+ return Promise.resolve([]);
+ }
+
+ /**
+ * Get the lesson password if needed. If not stored, it can ask the user to enter it.
+ *
+ * @param {number} lessonId Lesson ID.
+ * @param {boolean} [forceCache] Whether it should return cached data. Has priority over ignoreCache.
+ * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down).
+ * @param {boolean} [askPassword] True if we should ask for password if needed, false otherwise.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise<{password?: string, lesson?: any, accessInfo: any}>} Promise resolved when done.
+ */
+ getLessonPassword(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, askPassword?: boolean, siteId?: string)
+ : Promise<{password?: string, lesson?: any, accessInfo: any}> {
+
+ siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+ // Get access information to check if password is needed.
+ return this.lessonProvider.getAccessInformation(lessonId, forceCache, ignoreCache, siteId).then((info): any => {
+ if (info.preventaccessreasons && info.preventaccessreasons.length) {
+ const passwordNeeded = info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info);
+ if (passwordNeeded) {
+
+ // The lesson requires a password. Check if there is one in DB.
+ return this.lessonProvider.getStoredPassword(lessonId).catch(() => {
+ // No password found.
+ }).then((password) => {
+ if (password) {
+ return this.validatePassword(lessonId, info, password, forceCache, ignoreCache, siteId);
+ } else {
+ return Promise.reject(null);
+ }
+ }).catch(() => {
+ // No password or error validating it. Ask for it if allowed.
+ if (askPassword) {
+ return this.askUserPassword(info).then((password) => {
+ return this.validatePassword(lessonId, info, password, forceCache, ignoreCache, siteId);
+ });
+ }
+
+ // Cannot ask for password, reject.
+ return Promise.reject(info.preventaccessreasons[0].message);
+ });
+ } else {
+ // Lesson cannot be played, reject.
+ return Promise.reject(info.preventaccessreasons[0].message);
+ }
+ }
+
+ // Password not needed.
+ return { accessInfo: info };
+ });
+ }
+
+ /**
+ * Invalidate the prefetched content.
+ *
+ * @param {number} moduleId The module ID.
+ * @param {number} courseId The course ID the module belongs to.
+ * @return {Promise} Promise resolved when the data is invalidated.
+ */
+ invalidateContent(moduleId: number, courseId: number): Promise {
+ // Only invalidate the data that doesn't ignore cache when prefetching.
+ const promises = [];
+
+ promises.push(this.lessonProvider.invalidateLessonData(courseId));
+ promises.push(this.courseProvider.invalidateModule(moduleId));
+ promises.push(this.groupsProvider.invalidateActivityAllowedGroups(moduleId));
+
+ return Promise.all(promises);
+ }
+
+ /**
+ * Invalidate WS calls needed to determine module status.
+ *
+ * @param {any} module Module.
+ * @param {number} courseId Course ID the module belongs to.
+ * @return {Promise} Promise resolved when invalidated.
+ */
+ invalidateModule(module: any, courseId: number): Promise {
+ const siteId = this.sitesProvider.getCurrentSiteId();
+
+ // Invalidate data to determine if module is downloadable.
+ return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lesson) => {
+ const promises = [];
+
+ promises.push(this.lessonProvider.invalidateLessonData(courseId, siteId));
+ promises.push(this.lessonProvider.invalidateAccessInformation(lesson.id, siteId));
+
+ return Promise.all(promises);
+ });
+ }
+
+ /**
+ * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
+ *
+ * @param {any} module Module.
+ * @param {number} courseId Course ID the module belongs to.
+ * @return {boolean|Promise} Whether the module can be downloaded. The promise should never be rejected.
+ */
+ isDownloadable(module: any, courseId: number): boolean | Promise {
+ const siteId = this.sitesProvider.getCurrentSiteId();
+
+ return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lesson) => {
+ if (!this.lessonProvider.isLessonOffline(lesson)) {
+ return false;
+ }
+
+ // Check if there is any prevent access reason.
+ return this.lessonProvider.getAccessInformation(lesson.id, false, false, siteId).then((info) => {
+ // It's downloadable if there are no prevent access reasons or there is just 1 and it's password.
+ return !info.preventaccessreasons || !info.preventaccessreasons.length ||
+ (info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info));
+ });
+ });
+ }
+
+ /**
+ * Whether or not the handler is enabled on a site level.
+ *
+ * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled.
+ */
+ isEnabled(): boolean | Promise {
+ return this.lessonProvider.isPluginEnabled();
+ }
+
+ /**
+ * Prefetch a module.
+ *
+ * @param {any} module Module.
+ * @param {number} courseId Course ID the module belongs to.
+ * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
+ * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
+ * @return {Promise} Promise resolved when done.
+ */
+ prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise {
+ return this.prefetchPackage(module, courseId, single, this.prefetchLesson.bind(this));
+ }
+
+ /**
+ * Prefetch a lesson.
+ *
+ * @param {any} module Module.
+ * @param {number} courseId Course ID the module belongs to.
+ * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section.
+ * @param {String} siteId Site ID.
+ * @return {Promise} Promise resolved when done.
+ */
+ protected prefetchLesson(module: any, courseId: number, single: boolean, siteId: string): Promise {
+ let lesson,
+ password,
+ accessInfo;
+
+ return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lessonData) => {
+ lesson = lessonData;
+
+ // Get the lesson password if it's needed.
+ return this.getLessonPassword(lesson.id, false, true, single, siteId);
+ }).then((data) => {
+ password = data.password;
+ lesson = data.lesson || lesson;
+ accessInfo = data.accessInfo;
+
+ if (!this.lessonProvider.leftDuringTimed(accessInfo)) {
+ // The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
+ return this.lessonProvider.launchRetake(lesson.id, password, undefined, false, siteId).then(() => {
+ const promises = [];
+
+ // New data generated, update the download time and refresh the access info.
+ promises.push(this.filepoolProvider.updatePackageDownloadTime(siteId, this.component, module.id).catch(() => {
+ // Ignore errors.
+ }));
+
+ promises.push(this.lessonProvider.getAccessInformation(lesson.id, false, true, siteId).then((info) => {
+ accessInfo = info;
+ }));
+
+ return Promise.all(promises);
+ });
+ }
+ }).then(() => {
+ const promises = [],
+ retake = accessInfo.attemptscount;
+
+ // Download intro files and media files.
+ let files = lesson.mediafiles || [];
+ files = files.concat(this.getIntroFilesFromInstance(module, lesson));
+
+ promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id));
+
+ // Get the list of pages.
+ promises.push(this.lessonProvider.getPages(lesson.id, password, false, true, siteId).then((pages) => {
+ const subPromises = [];
+ let hasRandomBranch = false;
+
+ // Get the data for each page.
+ pages.forEach((data) => {
+ // Check if any page has a RANDOMBRANCH jump.
+ if (!hasRandomBranch) {
+ for (let i = 0; i < data.jumps.length; i++) {
+ if (data.jumps[i] == AddonModLessonProvider.LESSON_RANDOMBRANCH) {
+ hasRandomBranch = true;
+ break;
+ }
+ }
+ }
+
+ // Get the page data. We don't pass accessInfo because we don't need to calculate the offline data.
+ subPromises.push(this.lessonProvider.getPageData(lesson, data.page.id, password, false, true, false,
+ true, undefined, undefined, siteId).then((pageData) => {
+
+ // Download the page files.
+ let pageFiles = pageData.contentfiles || [];
+
+ pageData.answers.forEach((answer) => {
+ if (answer.answerfiles && answer.answerfiles.length) {
+ pageFiles = pageFiles.concat(answer.answerfiles);
+ }
+ if (answer.responsefiles && answer.responsefiles.length) {
+ pageFiles = pageFiles.concat(answer.responsefiles);
+ }
+ });
+
+ return this.filepoolProvider.addFilesToQueue(siteId, pageFiles, this.component, module.id);
+ }));
+ });
+
+ // Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch.
+ subPromises.push(this.lessonProvider.getPagesPossibleJumps(lesson.id, false, true, siteId).catch((error) => {
+ if (hasRandomBranch) {
+ // The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page.
+ return Promise.reject(this.translate.instant('addon.mod_lesson.errorprefetchrandombranch'));
+ } else {
+ return Promise.reject(error);
+ }
+ }));
+
+ return Promise.all(subPromises);
+ }));
+
+ // Prefetch user timers to be able to calculate timemodified in offline.
+ promises.push(this.lessonProvider.getTimers(lesson.id, false, true, siteId).catch(() => {
+ // Ignore errors.
+ }));
+
+ // Prefetch viewed pages in last retake to calculate progress.
+ promises.push(this.lessonProvider.getContentPagesViewedOnline(lesson.id, retake, false, true, siteId));
+
+ // Prefetch question attempts in last retake for offline calculations.
+ promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lesson.id, retake, false, undefined, false, true, siteId));
+
+ // Get module info to be able to handle links.
+ promises.push(this.courseProvider.getModuleBasicInfo(module.id, siteId));
+
+ if (accessInfo.canviewreports) {
+ // Prefetch reports data.
+ promises.push(this.groupsProvider.getActivityAllowedGroupsIfEnabled(module.id, undefined, siteId).then((groups) => {
+ const subPromises = [];
+
+ groups.forEach((group) => {
+ subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, group.id, false, true, siteId));
+ });
+
+ // Always get group 0, even if there are no groups.
+ subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, 0, false, true, siteId).then((data) => {
+ if (!data || !data.students) {
+ return;
+ }
+
+ // Prefetch the last retake for each user.
+ const retakePromises = [];
+
+ data.students.forEach((student) => {
+ if (!student.attempts || !student.attempts.length) {
+ return;
+ }
+
+ const lastRetake = student.attempts[student.attempts.length - 1];
+ if (!lastRetake) {
+ return;
+ }
+
+ retakePromises.push(this.lessonProvider.getUserRetake(lesson.id, lastRetake.try, student.id, false,
+ true, siteId));
+ });
+
+ return Promise.all(retakePromises);
+ }));
+
+ return Promise.all(subPromises);
+ }));
+ }
+
+ return Promise.all(promises);
+ });
+ }
+
+ /**
+ * Validate the password.
+ *
+ * @param {number} lessonId Lesson ID.
+ * @param {any} info Lesson access info.
+ * @param {string} pwd Password to check.
+ * @param {boolean} [forceCache] Whether it should return cached data. Has priority over ignoreCache.
+ * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down).
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise<{password: string, lesson: any, accessInfo: any}>} Promise resolved when done.
+ */
+ protected validatePassword(lessonId: number, info: any, pwd: string, forceCache?: boolean, ignoreCache?: boolean,
+ siteId?: string): Promise<{password: string, lesson: any, accessInfo: any}> {
+
+ siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+ return this.lessonProvider.getLessonWithPassword(lessonId, pwd, true, forceCache, ignoreCache, siteId).then((lesson) => {
+ // Password is ok, store it and return the data.
+ return this.lessonProvider.storePassword(lesson.id, pwd, siteId).then(() => {
+ return {
+ password: pwd,
+ lesson: lesson,
+ accessInfo: info
+ };
+ });
+ });
+ }
+}
diff --git a/src/core/login/pages/reconnect/reconnect.html b/src/core/login/pages/reconnect/reconnect.html
index 74350213e..f2b454762 100644
--- a/src/core/login/pages/reconnect/reconnect.html
+++ b/src/core/login/pages/reconnect/reconnect.html
@@ -33,7 +33,7 @@