diff --git a/src/addons/mod/lesson/components/components.module.ts b/src/addons/mod/lesson/components/components.module.ts
new file mode 100644
index 000000000..8999caf02
--- /dev/null
+++ b/src/addons/mod/lesson/components/components.module.ts
@@ -0,0 +1,39 @@
+// (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 { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { IonicModule } from '@ionic/angular';
+import { TranslateModule } from '@ngx-translate/core';
+
+import { CoreSharedModule } from '@/core/shared.module';
+import { AddonModLessonPasswordModalComponent } from './password-modal/password-modal';
+
+@NgModule({
+    declarations: [
+        AddonModLessonPasswordModalComponent,
+    ],
+    imports: [
+        CommonModule,
+        IonicModule,
+        TranslateModule.forChild(),
+        CoreSharedModule,
+    ],
+    providers: [
+    ],
+    exports: [
+        AddonModLessonPasswordModalComponent,
+    ],
+})
+export class AddonModLessonComponentsModule {}
diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.html b/src/addons/mod/lesson/components/password-modal/password-modal.html
new file mode 100644
index 000000000..ba0313e13
--- /dev/null
+++ b/src/addons/mod/lesson/components/password-modal/password-modal.html
@@ -0,0 +1,28 @@
+
+    
+        {{ 'core.login.password' | translate }}
+
+        
+            
+                
+            
+        
+    
+
+
+    
+
diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.ts b/src/addons/mod/lesson/components/password-modal/password-modal.ts
new file mode 100644
index 000000000..746e525b2
--- /dev/null
+++ b/src/addons/mod/lesson/components/password-modal/password-modal.ts
@@ -0,0 +1,57 @@
+// (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 { Component, ViewChild, ElementRef } from '@angular/core';
+
+import { CoreSites } from '@services/sites';
+import { CoreDomUtils } from '@services/utils/dom';
+import { ModalController } from '@singletons';
+
+
+/**
+ * Modal that asks the password for a lesson.
+ */
+@Component({
+    selector: 'page-addon-mod-lesson-password-modal',
+    templateUrl: 'password-modal.html',
+})
+export class AddonModLessonPasswordModalComponent {
+
+    @ViewChild('passwordForm') formElement?: ElementRef;
+
+    /**
+     * Send the password back.
+     *
+     * @param e Event.
+     * @param password The input element.
+     */
+    submitPassword(e: Event, password: HTMLInputElement): void {
+        e.preventDefault();
+        e.stopPropagation();
+
+        CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
+
+        ModalController.instance.dismiss(password.value);
+    }
+
+    /**
+     * Close modal.
+     */
+    closeModal(): void {
+        CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
+
+        ModalController.instance.dismiss();
+    }
+
+}
diff --git a/src/addons/mod/lesson/lesson.module.ts b/src/addons/mod/lesson/lesson.module.ts
index 83df48fce..9b0785854 100644
--- a/src/addons/mod/lesson/lesson.module.ts
+++ b/src/addons/mod/lesson/lesson.module.ts
@@ -12,13 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { NgModule } from '@angular/core';
+import { APP_INITIALIZER, NgModule } from '@angular/core';
 
+import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
+import { CoreCronDelegate } from '@services/cron';
 import { CORE_SITE_SCHEMAS } from '@services/sites';
+import { AddonModLessonComponentsModule } from './components/components.module';
 import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson';
+import { AddonModLessonPrefetchHandler } from './services/handlers/prefetch';
+import { AddonModLessonSyncCronHandler } from './services/handlers/sync-cron';
 
 @NgModule({
     imports: [
+        AddonModLessonComponentsModule,
     ],
     providers: [
         {
@@ -26,6 +32,15 @@ import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson';
             useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA],
             multi: true,
         },
+        {
+            provide: APP_INITIALIZER,
+            multi: true,
+            deps: [],
+            useFactory: () => () => {
+                CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModLessonPrefetchHandler.instance);
+                CoreCronDelegate.instance.register(AddonModLessonSyncCronHandler.instance);
+            },
+        },
     ],
 })
 export class AddonModLessonModule {}
diff --git a/src/addons/mod/lesson/services/database/lesson.ts b/src/addons/mod/lesson/services/database/lesson.ts
index ec4b6ec2b..843a690d6 100644
--- a/src/addons/mod/lesson/services/database/lesson.ts
+++ b/src/addons/mod/lesson/services/database/lesson.ts
@@ -15,7 +15,7 @@
 import { CoreSiteSchema } from '@services/sites';
 
 /**
- * Database variables for AddonModLessonOfflineProvider.
+ * Database variables for AddonModLessonProvider.
  */
 export const PASSWORD_TABLE_NAME = 'addon_mod_lesson_password';
 export const SITE_SCHEMA: CoreSiteSchema = {
@@ -145,6 +145,39 @@ export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
     ],
 };
 
+/**
+ * Database variables for AddonModLessonSyncProvider.
+ */
+export const RETAKES_FINISHED_SYNC_TABLE_NAME = 'addon_mod_lesson_retakes_finished_sync';
+export const SYNC_SITE_SCHEMA: CoreSiteSchema = {
+    name: 'AddonModLessonSyncProvider',
+    version: 1,
+    tables: [
+        {
+            name: RETAKES_FINISHED_SYNC_TABLE_NAME,
+            columns: [
+                {
+                    name: 'lessonid',
+                    type: 'INTEGER',
+                    primaryKey: true,
+                },
+                {
+                    name: 'retake',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'pageid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'timefinished',
+                    type: 'INTEGER',
+                },
+            ],
+        },
+    ],
+};
+
 /**
  * Lesson retake data.
  */
@@ -183,3 +216,13 @@ export type AddonModLessonPageAttemptDBRecord = {
     answerid: number | null;
     useranswer: string | null;
 };
+
+/**
+ * Data about a retake finished in sync.
+ */
+export type AddonModLessonRetakeFinishedInSyncDBRecord = {
+    lessonid: number;
+    retake: number;
+    pageid: number;
+    timefinished: number;
+};
diff --git a/src/addons/mod/lesson/services/handlers/prefetch.ts b/src/addons/mod/lesson/services/handlers/prefetch.ts
new file mode 100644
index 000000000..f81535a66
--- /dev/null
+++ b/src/addons/mod/lesson/services/handlers/prefetch.ts
@@ -0,0 +1,576 @@
+// (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 { CoreCanceledError } from '@classes/errors/cancelederror';
+import { CoreError } from '@classes/errors/error';
+
+import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
+import { CoreCourse, CoreCourseCommonModWSOptions, CoreCourseAnyModuleData } from '@features/course/services/course';
+import { CoreFilepool } from '@services/filepool';
+import { CoreGroups } from '@services/groups';
+import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate';
+import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreWSExternalFile } from '@services/ws';
+import { makeSingleton, ModalController, Translate } from '@singletons';
+import { AddonModLessonPasswordModalComponent } from '../../components/password-modal/password-modal';
+import {
+    AddonModLesson,
+    AddonModLessonGetAccessInformationWSResponse,
+    AddonModLessonLessonWSData,
+    AddonModLessonPasswordOptions,
+    AddonModLessonProvider,
+} from '../lesson';
+import { AddonModLessonSync, AddonModLessonSyncResult } from '../lesson-sync';
+
+/**
+ * Handler to prefetch lessons.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
+
+    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$/;
+
+    /**
+     * Ask password.
+     *
+     * @return Promise resolved with the password.
+     */
+    protected async askUserPassword(): Promise {
+        // Create and show the modal.
+        const modal = await ModalController.instance.create({
+            component: AddonModLessonPasswordModalComponent,
+        });
+
+        await modal.present();
+
+        const password =  await modal.onWillDismiss();
+
+        if (typeof password != 'string') {
+            throw new CoreCanceledError();
+        }
+
+        return password;
+    }
+
+    /**
+     * Get the download size of a module.
+     *
+     * @param module Module.
+     * @param courseId Course ID the module belongs to.
+     * @param single True if we're downloading a single module, false if we're downloading a whole section.
+     * @return Promise resolved with the size.
+     */
+    async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise {
+        const siteId = CoreSites.instance.getCurrentSiteId();
+
+        let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId });
+
+        // Get the lesson password if it's needed.
+        const passwordData = await this.getLessonPassword(lesson.id, {
+            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+            askPassword: single,
+            siteId,
+        });
+
+        lesson = passwordData.lesson || lesson;
+
+        // Get intro files and media files.
+        let files = lesson.mediafiles || [];
+        files = files.concat(this.getIntroFilesFromInstance(module, lesson));
+
+        const result = await CorePluginFileDelegate.instance.getFilesDownloadSize(files);
+
+        // Get the pages to calculate the size.
+        const pages = await AddonModLesson.instance.getPages(lesson.id, {
+            cmId: module.id,
+            password: passwordData.password,
+            siteId,
+        });
+
+        pages.forEach((page) => {
+            result.size += page.filessizetotal;
+        });
+
+        return result;
+    }
+
+    /**
+     * Get the lesson password if needed. If not stored, it can ask the user to enter it.
+     *
+     * @param lessonId Lesson ID.
+     * @param options Other options.
+     * @return Promise resolved when done.
+     */
+    async getLessonPassword(
+        lessonId: number,
+        options: AddonModLessonGetPasswordOptions = {},
+    ): Promise {
+
+        options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
+
+        // Get access information to check if password is needed.
+        const accessInfo = await AddonModLesson.instance.getAccessInformation(lessonId, options);
+
+        if (!accessInfo.preventaccessreasons.length) {
+            // Password not needed.
+            return { accessInfo };
+        }
+
+        const passwordNeeded = accessInfo.preventaccessreasons.length == 1 &&
+            AddonModLesson.instance.isPasswordProtected(accessInfo);
+
+        if (!passwordNeeded) {
+            // Lesson cannot be played, reject.
+            throw new CoreError(accessInfo.preventaccessreasons[0].message);
+        }
+
+        // The lesson requires a password. Check if there is one in DB.
+        let password = await CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getStoredPassword(lessonId));
+
+        if (password) {
+            try {
+                return this.validatePassword(lessonId, accessInfo, password, options);
+            } catch {
+                // Error validating it.
+            }
+        }
+
+        // Ask for the password if allowed.
+        if (!options.askPassword) {
+            // Cannot ask for password, reject.
+            throw new CoreError(accessInfo.preventaccessreasons[0].message);
+        }
+
+        password = await this.askUserPassword();
+
+        return this.validatePassword(lessonId, accessInfo, password, options);
+    }
+
+    /**
+     * Invalidate the prefetched content.
+     *
+     * @param moduleId The module ID.
+     * @param courseId The course ID the module belongs to.
+     * @return Promise resolved when the data is invalidated.
+     */
+    async invalidateContent(moduleId: number, courseId: number): Promise {
+        // Only invalidate the data that doesn't ignore cache when prefetching.
+        await Promise.all([
+            AddonModLesson.instance.invalidateLessonData(courseId),
+            CoreCourse.instance.invalidateModule(moduleId),
+            CoreGroups.instance.invalidateActivityAllowedGroups(moduleId),
+        ]);
+    }
+
+    /**
+     * Invalidate WS calls needed to determine module status.
+     *
+     * @param module Module.
+     * @param courseId Course ID the module belongs to.
+     * @return Promise resolved when invalidated.
+     */
+    async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise {
+        // Invalidate data to determine if module is downloadable.
+        const siteId = CoreSites.instance.getCurrentSiteId();
+
+        const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, {
+            readingStrategy: CoreSitesReadingStrategy.PreferCache,
+            siteId,
+        });
+
+        await Promise.all([
+            AddonModLesson.instance.invalidateLessonData(courseId, siteId),
+            AddonModLesson.instance.invalidateAccessInformation(lesson.id, siteId),
+        ]);
+    }
+
+    /**
+     * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
+     *
+     * @param module Module.
+     * @param courseId Course ID the module belongs to.
+     * @return Whether the module can be downloaded. The promise should never be rejected.
+     */
+    async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise {
+        const siteId = CoreSites.instance.getCurrentSiteId();
+
+        const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId });
+        const accessInfo = await AddonModLesson.instance.getAccessInformation(lesson.id, { cmId: module.id, siteId });
+
+        // If it's a student and lesson isn't offline, it isn't downloadable.
+        if (!accessInfo.canviewreports && !AddonModLesson.instance.isLessonOffline(lesson)) {
+            return false;
+        }
+
+        // It's downloadable if there are no prevent access reasons or there is just 1 and it's password.
+        return !accessInfo.preventaccessreasons.length ||
+            (accessInfo.preventaccessreasons.length == 1 && AddonModLesson.instance.isPasswordProtected(accessInfo));
+    }
+
+    /**
+     * Whether or not the handler is enabled on a site level.
+     *
+     * @return Promise resolved with a boolean indicating if the handler is enabled.
+     */
+    isEnabled(): Promise {
+        return AddonModLesson.instance.isPluginEnabled();
+    }
+
+    /**
+     * Prefetch a module.
+     *
+     * @param module Module.
+     * @param courseId Course ID the module belongs to.
+     * @param single True if we're downloading a single module, false if we're downloading a whole section.
+     * @param dirPath Path of the directory where to store all the content files.
+     * @return Promise resolved when done.
+     */
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean, dirPath?: string): Promise {
+        return this.prefetchPackage(module, courseId, this.prefetchLesson.bind(this, module, courseId, single));
+    }
+
+    /**
+     * Prefetch a lesson.
+     *
+     * @param module Module.
+     * @param courseId Course ID the module belongs to.
+     * @param single True if we're downloading a single module, false if we're downloading a whole section.
+     * @return Promise resolved when done.
+     */
+    protected async prefetchLesson(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise {
+        const siteId = CoreSites.instance.getCurrentSiteId();
+        courseId = courseId || module.course || 1;
+
+        const commonOptions = {
+            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+            siteId,
+        };
+        const modOptions = {
+            cmId: module.id,
+            ...commonOptions, // Include all common options.
+        };
+
+        let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, commonOptions);
+
+        // Get the lesson password if it's needed.
+        const passwordData = await this.getLessonPassword(lesson.id, {
+            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+            askPassword: single,
+            siteId,
+        });
+
+        lesson = passwordData.lesson || lesson;
+        let accessInfo = passwordData.accessInfo;
+        const password = passwordData.password;
+
+        if (AddonModLesson.instance.isLessonOffline(lesson) && !AddonModLesson.instance.leftDuringTimed(accessInfo)) {
+            // The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
+            accessInfo = await this.launchRetake(lesson.id, password, modOptions, siteId);
+        }
+
+        const promises: Promise[] = [];
+
+        // Download intro files and media files.
+        const files = (lesson.mediafiles || []).concat(this.getIntroFilesFromInstance(module, lesson));
+        promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id));
+
+        if (AddonModLesson.instance.isLessonOffline(lesson)) {
+            promises.push(this.prefetchPlayData(lesson, password, accessInfo.attemptscount, modOptions));
+        }
+
+        if (accessInfo.canviewreports) {
+            promises.push(this.prefetchGroupInfo(module.id, lesson.id, modOptions));
+            promises.push(this.prefetchReportsData(module.id, lesson.id, modOptions));
+        }
+
+        await Promise.all(promises);
+    }
+
+    /**
+     * Launch a retake and return the updated access information.
+     *
+     * @param lessonId Lesson ID.
+     * @param password Password (if needed).
+     * @param modOptions Options.
+     * @param siteId Site ID.
+     */
+    protected async launchRetake(
+        lessonId: number,
+        password: string | undefined,
+        modOptions: CoreCourseCommonModWSOptions,
+        siteId: string,
+    ): Promise {
+        // The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
+        await AddonModLesson.instance.launchRetake(lessonId, password, undefined, false, siteId);
+
+        const results = await Promise.all([
+            CoreUtils.instance.ignoreErrors(CoreFilepool.instance.updatePackageDownloadTime(siteId, this.component, module.id)),
+            AddonModLesson.instance.getAccessInformation(lessonId, modOptions),
+        ]);
+
+        return results[1];
+    }
+
+    /**
+     * Prefetch data to play the lesson in offline.
+     *
+     * @param lesson Lesson.
+     * @param password Password (if needed).
+     * @param retake Retake to prefetch.
+     * @param options Options.
+     * @return Promise resolved when done.
+     */
+    protected async prefetchPlayData(
+        lesson: AddonModLessonLessonWSData,
+        password: string | undefined,
+        retake: number,
+        modOptions: CoreCourseCommonModWSOptions,
+    ): Promise {
+        const passwordOptions = {
+            password,
+            ...modOptions, // Include all mod options.
+        };
+
+        await Promise.all([
+            this.prefetchPagesData(lesson, passwordOptions),
+            // Prefetch user timers to be able to calculate timemodified in offline.
+            CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getTimers(lesson.id, modOptions)),
+            // Prefetch viewed pages in last retake to calculate progress.
+            AddonModLesson.instance.getContentPagesViewedOnline(lesson.id, retake, modOptions),
+            // Prefetch question attempts in last retake for offline calculations.
+            AddonModLesson.instance.getQuestionsAttemptsOnline(lesson.id, retake, modOptions),
+        ]);
+    }
+
+    /**
+     * Prefetch data related to pages.
+     *
+     * @param lesson Lesson.
+     * @param options Options.
+     * @return Promise resolved when done.
+     */
+    protected async prefetchPagesData(
+        lesson: AddonModLessonLessonWSData,
+        options: AddonModLessonPasswordOptions,
+    ): Promise {
+        const pages = await AddonModLesson.instance.getPages(lesson.id, options);
+
+        let hasRandomBranch = false;
+
+        // Get the data for each page.
+        const promises = pages.map(async (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.
+            const pageData = await AddonModLesson.instance.getPageData(lesson, data.page.id, {
+                includeContents: true,
+                includeOfflineData: false,
+                ...options, // Include all options.
+            });
+
+            // Download the page files.
+            let pageFiles = pageData.contentfiles || [];
+
+            pageData.answers.forEach((answer) => {
+                pageFiles = pageFiles.concat(answer.answerfiles);
+                pageFiles = pageFiles.concat(answer.responsefiles);
+            });
+
+            await CoreFilepool.instance.addFilesToQueue(options.siteId!, pageFiles, this.component, module.id);
+        });
+
+        // Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch.
+        promises.push(this.prefetchPossibleJumps(lesson.id, hasRandomBranch, options));
+
+        await Promise.all(promises);
+    }
+
+    /**
+     * Prefetch possible jumps.
+     *
+     * @param lessonId Lesson ID.
+     * @param hasRandomBranch Whether any page has a random branch jump.
+     * @param modOptions Options.
+     * @return Promise resolved when done.
+     */
+    protected async prefetchPossibleJumps(
+        lessonId: number,
+        hasRandomBranch: boolean,
+        modOptions: CoreCourseCommonModWSOptions,
+    ): Promise {
+        try {
+            await AddonModLesson.instance.getPagesPossibleJumps(lessonId, modOptions);
+        } catch (error) {
+            if (hasRandomBranch) {
+                // The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page.
+                throw new CoreError(Translate.instance.instant('addon.mod_lesson.errorprefetchrandombranch'));
+            }
+
+            throw error;
+        }
+    }
+
+    /**
+     * Prefetch group info.
+     *
+     * @param moduleId Module ID.
+     * @param lessonId Lesson ID.
+     * @param modOptions Options.
+     * @return Promise resolved when done.
+     */
+    protected async prefetchGroupInfo(
+        moduleId: number,
+        lessonId: number,
+        modOptions: CoreCourseCommonModWSOptions,
+    ): Promise {
+        const groupInfo = await CoreGroups.instance.getActivityGroupInfo(moduleId, false, undefined, modOptions.siteId, true);
+
+        await Promise.all(groupInfo.groups?.map(async (group) => {
+            await AddonModLesson.instance.getRetakesOverview(lessonId, {
+                groupId: group.id,
+                ...modOptions, // Include all options.
+            });
+        }) || []);
+    }
+
+    /**
+     * Prefetch reports data.
+     *
+     * @param moduleId Module ID.
+     * @param lessonId Lesson ID.
+     * @param modOptions Options.
+     * @return Promise resolved when done.
+     */
+    protected async prefetchReportsData(
+        moduleId: number,
+        lessonId: number,
+        modOptions: CoreCourseCommonModWSOptions,
+    ): Promise {
+        // Always get all participants, even if there are no groups.
+        const data = await AddonModLesson.instance.getRetakesOverview(lessonId, modOptions);
+        if (!data || !data.students) {
+            return;
+        }
+
+        // Prefetch the last retake for each user.
+        await Promise.all(data.students.map(async (student) => {
+            const lastRetake = student.attempts?.[student.attempts.length - 1];
+            if (!lastRetake) {
+                return;
+            }
+
+            const attempt = await AddonModLesson.instance.getUserRetake(lessonId, lastRetake.try, {
+                userId: student.id,
+                ...modOptions, // Include all options.
+            });
+
+            if (!attempt?.answerpages) {
+                return;
+            }
+
+            // Download embedded files in essays.
+            const files: CoreWSExternalFile[] = [];
+            attempt.answerpages.forEach((answerPage) => {
+                if (!answerPage.page || answerPage.page.qtype != AddonModLessonProvider.LESSON_PAGE_ESSAY) {
+                    return;
+                }
+
+                answerPage.answerdata?.answers?.forEach((answer) => {
+                    files.push(...CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(answer[0]));
+                });
+            });
+
+            await CoreFilepool.instance.addFilesToQueue(modOptions.siteId!, files, this.component, moduleId);
+        }));
+    }
+
+    /**
+     * Validate the password.
+     *
+     * @param lessonId Lesson ID.
+     * @param info Lesson access info.
+     * @param pwd Password to check.
+     * @param options Other options.
+     * @return Promise resolved when done.
+     */
+    protected async validatePassword(
+        lessonId: number,
+        accessInfo: AddonModLessonGetAccessInformationWSResponse,
+        password: string,
+        options: CoreCourseCommonModWSOptions = {},
+    ): Promise {
+
+        options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
+
+        const lesson = await AddonModLesson.instance.getLessonWithPassword(lessonId, {
+            password,
+            ...options, // Include all options.
+        });
+
+        // Password is ok, store it and return the data.
+        await AddonModLesson.instance.storePassword(lesson.id, password, options.siteId);
+
+        return {
+            password,
+            lesson,
+            accessInfo,
+        };
+    }
+
+    /**
+     * Sync a module.
+     *
+     * @param module Module.
+     * @param courseId Course ID the module belongs to
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when done.
+     */
+    sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise {
+        return AddonModLessonSync.instance.syncLesson(module.instance!, false, false, siteId);
+    }
+
+}
+
+export class AddonModLessonPrefetchHandler extends makeSingleton(AddonModLessonPrefetchHandlerService) {}
+
+/**
+ * Options to pass to get lesson password.
+ */
+export type AddonModLessonGetPasswordOptions = CoreCourseCommonModWSOptions & {
+    askPassword?: boolean; // True if we should ask for password if needed, false otherwise.
+};
+
+/**
+ * Result of getLessonPassword.
+ */
+export type AddonModLessonGetPasswordResult = {
+    password?: string;
+    lesson?: AddonModLessonLessonWSData;
+    accessInfo: AddonModLessonGetAccessInformationWSResponse;
+};
diff --git a/src/addons/mod/lesson/services/handlers/sync-cron.ts b/src/addons/mod/lesson/services/handlers/sync-cron.ts
new file mode 100644
index 000000000..c2dfce3ca
--- /dev/null
+++ b/src/addons/mod/lesson/services/handlers/sync-cron.ts
@@ -0,0 +1,52 @@
+// (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 '@services/cron';
+import { makeSingleton } from '@singletons';
+import { AddonModLessonSync } from '../lesson-sync';
+
+/**
+ * Synchronization cron handler.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModLessonSyncCronHandlerService implements CoreCronHandler {
+
+    name = 'AddonModLessonSyncCronHandler';
+
+    /**
+     * 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 AddonModLessonSync.instance.syncAllLessons(siteId, force);
+    }
+
+    /**
+     * Get the time between consecutive executions.
+     *
+     * @return Time between consecutive executions (in ms).
+     */
+    getInterval(): number {
+        return AddonModLessonSync.instance.syncInterval;
+    }
+
+}
+
+export class AddonModLessonSyncCronHandler extends makeSingleton(AddonModLessonSyncCronHandlerService) {}
diff --git a/src/addons/mod/lesson/services/lesson-sync.ts b/src/addons/mod/lesson/services/lesson-sync.ts
new file mode 100644
index 000000000..48034d781
--- /dev/null
+++ b/src/addons/mod/lesson/services/lesson-sync.ts
@@ -0,0 +1,518 @@
+// (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 { CoreError } from '@classes/errors/error';
+import { CoreNetworkError } from '@classes/errors/network-error';
+import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
+import { CoreCourse } from '@features/course/services/course';
+import { CoreCourseLogHelper } from '@features/course/services/log-helper';
+import { CoreApp } from '@services/app';
+import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
+import { CoreSync } from '@services/sync';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreTimeUtils } from '@services/utils/time';
+import { CoreUrlUtils } from '@services/utils/url';
+import { CoreUtils } from '@services/utils/utils';
+import { makeSingleton, Translate } from '@singletons';
+import { CoreEvents, CoreEventSiteData } from '@singletons/events';
+import { AddonModLessonRetakeFinishedInSyncDBRecord, RETAKES_FINISHED_SYNC_TABLE_NAME } from './database/lesson';
+import { AddonModLessonGetPasswordResult, AddonModLessonPrefetchHandler } from './handlers/prefetch';
+import { AddonModLesson, AddonModLessonLessonWSData, AddonModLessonProvider } from './lesson';
+import { AddonModLessonOffline, AddonModLessonPageAttemptRecord } from './lesson-offline';
+
+/**
+ * Service to sync lesson.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvider {
+
+    static readonly AUTO_SYNCED = 'addon_mod_lesson_autom_synced';
+
+    protected componentTranslate?: string;
+
+    constructor() {
+        super('AddonModLessonSyncProvider');
+    }
+
+    /**
+     * Unmark a retake as finished in a synchronization.
+     *
+     * @param lessonId Lesson ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when done.
+     */
+    async deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise {
+        const site = await CoreSites.instance.getSite(siteId);
+
+        // Ignore errors, maybe there is none.
+        await CoreUtils.instance.ignoreErrors(site.getDb().deleteRecords(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId }));
+    }
+
+    /**
+     * Get a retake finished in a synchronization for a certain lesson (if any).
+     *
+     * @param lessonId Lesson ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with the retake entry (undefined if no retake).
+     */
+    async getRetakeFinishedInSync(
+        lessonId: number,
+        siteId?: string,
+    ): Promise {
+        const site = await CoreSites.instance.getSite(siteId);
+
+        return CoreUtils.instance.ignoreErrors(site.getDb().getRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId }));
+    }
+
+    /**
+     * Check if a lesson has data to synchronize.
+     *
+     * @param lessonId Lesson ID.
+     * @param retake Retake number.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with boolean: whether it has data to sync.
+     */
+    async hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise {
+
+        const [hasAttempts, hasFinished] = await Promise.all([
+            CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasRetakeAttempts(lessonId, retake, siteId)),
+            CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasFinishedRetake(lessonId, siteId)),
+        ]);
+
+        return !!(hasAttempts || hasFinished);
+    }
+
+    /**
+     * Mark a retake as finished in a synchronization.
+     *
+     * @param lessonId Lesson ID.
+     * @param retake The retake number.
+     * @param pageId The page ID to start reviewing from.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when done.
+     */
+    async setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise {
+        const site = await CoreSites.instance.getSite(siteId);
+
+        await site.getDb().insertRecord(RETAKES_FINISHED_SYNC_TABLE_NAME,  {
+            lessonid: lessonId,
+            retake: Number(retake),
+            pageid: Number(pageId),
+            timefinished: CoreTimeUtils.instance.timestamp(),
+        });
+    }
+
+    /**
+     * Try to synchronize all the lessons 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.
+     */
+    syncAllLessons(siteId?: string, force?: boolean): Promise {
+        return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId);
+    }
+
+    /**
+     * Sync all lessons on a site.
+     *
+     * @param force Wether to force sync not depending on last execution.
+     * @param siteId Site ID to sync.
+     * @param Promise resolved if sync is successful, rejected if sync fails.
+     */
+    protected async syncAllLessonsFunc(force: boolean, siteId: string): Promise {
+        // Get all the lessons that have something to be synchronized.
+        const lessons = await AddonModLessonOffline.instance.getAllLessonsWithData(siteId);
+
+        // Sync all lessons that need it.
+        await Promise.all(lessons.map(async (lesson) => {
+            const result = force ?
+                await this.syncLesson(lesson.id, false, false, siteId) :
+                await this.syncLessonIfNeeded(lesson.id, false, siteId);
+
+            if (result?.updated) {
+                // Sync successful, send event.
+                CoreEvents.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, {
+                    lessonId: lesson.id,
+                    warnings: result.warnings,
+                }, siteId);
+            }
+        }));
+    }
+
+    /**
+     * Sync a lesson only if a certain time has passed since the last time.
+     *
+     * @param lessonId Lesson ID.
+     * @param askPreflight Whether we should ask for password if needed.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the lesson is synced or if it doesn't need to be synced.
+     */
+    async syncLessonIfNeeded(
+        lessonId: number,
+        askPassword?: boolean,
+        siteId?: string,
+    ): Promise {
+        const needed = await this.isSyncNeeded(lessonId, siteId);
+
+        if (needed) {
+            return this.syncLesson(lessonId, askPassword, false, siteId);
+        }
+    }
+
+    /**
+     * Try to synchronize a lesson.
+     *
+     * @param lessonId Lesson ID.
+     * @param askPassword True if we should ask for password if needed, false otherwise.
+     * @param ignoreBlock True to ignore the sync block setting.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved in success.
+     */
+    async syncLesson(
+        lessonId: number,
+        askPassword?: boolean,
+        ignoreBlock?: boolean,
+        siteId?: string,
+    ): Promise {
+        siteId = siteId || CoreSites.instance.getCurrentSiteId();
+        this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('lesson');
+
+        let syncPromise = this.getOngoingSync(lessonId, siteId);
+        if (syncPromise) {
+            // There's already a sync ongoing for this lesson, return the promise.
+            return syncPromise;
+        }
+
+        // Verify that lesson isn't blocked.
+        if (!ignoreBlock && CoreSync.instance.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) {
+            this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.');
+
+            throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
+        }
+
+        this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId);
+
+        syncPromise = this.performSyncLesson(lessonId, askPassword, ignoreBlock, siteId);
+
+        return this.addOngoingSync(lessonId, syncPromise, siteId);
+    }
+
+    /**
+     * Try to synchronize a lesson.
+     *
+     * @param lessonId Lesson ID.
+     * @param askPassword True if we should ask for password if needed, false otherwise.
+     * @param ignoreBlock True to ignore the sync block setting.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved in success.
+     */
+    protected async performSyncLesson(
+        lessonId: number,
+        askPassword?: boolean,
+        ignoreBlock?: boolean,
+        siteId?: string,
+    ): Promise {
+        // Sync offline logs.
+        await CoreUtils.instance.ignoreErrors(
+            CoreCourseLogHelper.instance.syncActivity(AddonModLessonProvider.COMPONENT, lessonId, siteId),
+        );
+
+        const result: AddonModLessonSyncResult = {
+            warnings: [],
+            updated: false,
+        };
+
+        // Try to synchronize the page attempts first.
+        const passwordData = await this.syncAttempts(lessonId, result, askPassword, siteId);
+
+        // Now sync the retake.
+        await this.syncRetake(lessonId, result, passwordData, askPassword, ignoreBlock, siteId);
+
+        if (result.updated && result.courseId) {
+            try {
+                // Data has been sent to server, update data.
+                const module = await CoreCourse.instance.getModuleBasicInfoByInstance(lessonId, 'lesson', siteId);
+                await this.prefetchAfterUpdate(AddonModLessonPrefetchHandler.instance, module, result.courseId, undefined, siteId);
+            } catch {
+                // Ignore errors.
+            }
+        }
+
+        // Sync finished, set sync time.
+        await CoreUtils.instance.ignoreErrors(this.setSyncTime(String(lessonId), siteId));
+
+        // All done, return the result.
+        return result;
+    }
+
+    /**
+     * Sync all page attempts.
+     *
+     * @param lessonId Lesson ID.
+     * @param result Sync result where to store the result.
+     * @param askPassword True if we should ask for password if needed, false otherwise.
+     * @param siteId Site ID. If not defined, current site.
+     */
+    protected async syncAttempts(
+        lessonId: number,
+        result: AddonModLessonSyncResult,
+        askPassword?: boolean,
+        siteId?: string,
+    ): Promise {
+        let attempts = await AddonModLessonOffline.instance.getLessonAttempts(lessonId, siteId);
+
+        if (!attempts.length) {
+            return;
+        } else if (!CoreApp.instance.isOnline()) {
+            // Cannot sync in offline.
+            throw new CoreNetworkError();
+        }
+
+        result.courseId = attempts[0].courseid;
+        const attemptsLength = attempts.length;
+
+        // Get the info, access info and the lesson password if needed.
+        const lesson = await AddonModLesson.instance.getLessonById(result.courseId, lessonId, { siteId });
+
+        const passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, {
+            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+            askPassword,
+            siteId,
+        });
+
+        const promises: Promise[] = [];
+        passwordData.lesson = passwordData.lesson || lesson;
+
+        // Filter the attempts, get only the ones that belong to the current retake.
+        attempts = attempts.filter((attempt) => {
+            if (attempt.retake == passwordData.accessInfo.attemptscount) {
+                return true;
+            }
+
+            // Attempt doesn't belong to current retake, delete.
+            promises.push(CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.deleteAttempt(
+                lesson.id,
+                attempt.retake,
+                attempt.pageid,
+                attempt.timemodified,
+                siteId,
+            )));
+
+            return false;
+        });
+
+        if (attempts.length != attemptsLength) {
+            // Some attempts won't be sent, add a warning.
+            result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
+                component: this.componentTranslate,
+                name: lesson.name,
+                error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'),
+            }));
+        }
+
+        await Promise.all(promises);
+
+        if (!attempts.length) {
+            return passwordData;
+        }
+
+        // Send the attempts in the same order they were answered.
+        attempts.sort((a, b) => a.timemodified - b.timemodified);
+
+        const promisesData = attempts.map((attempt) => ({
+            function: this.sendAttempt.bind(this, lesson, passwordData.password, attempt, result, siteId),
+            blocking: true,
+        }));
+
+        await CoreUtils.instance.executeOrderedPromises(promisesData);
+
+        return passwordData;
+    }
+
+    /**
+     * Send an attempt to the site and delete it afterwards.
+     *
+     * @param lesson Lesson.
+     * @param password Password (if any).
+     * @param attempt Attempt to send.
+     * @param result Result where to store the data.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when done.
+     */
+    protected async sendAttempt(
+        lesson: AddonModLessonLessonWSData,
+        password: string,
+        attempt: AddonModLessonPageAttemptRecord,
+        result: AddonModLessonSyncResult,
+        siteId?: string,
+    ): Promise {
+        const retake = attempt.retake;
+        const pageId = attempt.pageid;
+        const timemodified = attempt.timemodified;
+
+        try {
+            // Send the page data.
+            await AddonModLesson.instance.processPageOnline(lesson.id, attempt.pageid, attempt.data || {}, {
+                password,
+                siteId,
+            });
+
+            result.updated = true;
+
+            await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId);
+        } catch (error) {
+            if (!error || !CoreUtils.instance.isWebServiceError(error)) {
+                // Couldn't connect to server.
+                throw error;
+            }
+
+            // The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it.
+            result.updated = true;
+
+            await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId);
+
+            // Attempt deleted, add a warning.
+            result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
+                component: this.componentTranslate,
+                name: lesson.name,
+                error: CoreTextUtils.instance.getErrorMessageFromError(error),
+            }));
+        }
+    }
+
+    /**
+     * Sync retake.
+     *
+     * @param lessonId Lesson ID.
+     * @param result Sync result where to store the result.
+     * @param passwordData Password data. If not provided it will be calculated.
+     * @param askPassword True if we should ask for password if needed, false otherwise.
+     * @param ignoreBlock True to ignore the sync block setting.
+     * @param siteId Site ID. If not defined, current site.
+     */
+    protected async syncRetake(
+        lessonId: number,
+        result: AddonModLessonSyncResult,
+        passwordData?: AddonModLessonGetPasswordResult,
+        askPassword?: boolean,
+        ignoreBlock?: boolean,
+        siteId?: string,
+    ): Promise {
+        // Attempts sent or there was none. If there is a finished retake, send it.
+        const retake = await CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.getRetake(lessonId, siteId));
+
+        if (!retake) {
+            // No retake to sync.
+            return;
+        }
+
+        if (!retake.finished) {
+            // The retake isn't marked as finished, nothing to send. Delete the retake.
+            await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
+
+            return;
+        } else if (!CoreApp.instance.isOnline()) {
+            // Cannot sync in offline.
+            throw new CoreNetworkError();
+        }
+
+        result.courseId = retake.courseid || result.courseId;
+
+        if (!passwordData?.lesson) {
+            // Retrieve the needed data.
+            const lesson = await AddonModLesson.instance.getLessonById(result.courseId!, lessonId, { siteId });
+            passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, {
+                readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+                askPassword,
+                siteId,
+            });
+
+            passwordData.lesson = passwordData.lesson || lesson;
+        }
+
+        if (retake.retake != passwordData.accessInfo.attemptscount) {
+            // The retake changed, add a warning if it isn't there already.
+            if (!result.warnings.length) {
+                result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
+                    component: this.componentTranslate,
+                    name: passwordData.lesson.name,
+                    error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'),
+                }));
+            }
+
+            await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
+        }
+
+        try {
+            // All good, finish the retake.
+            const response = await AddonModLesson.instance.finishRetakeOnline(lessonId, {
+                password: passwordData.password,
+                siteId,
+            });
+
+            result.updated = true;
+
+            // Mark the retake as finished in a sync if it can be reviewed.
+            if (!ignoreBlock && response.data?.reviewlesson) {
+                const params = CoreUrlUtils.instance.extractUrlParams( response.data.reviewlesson.value);
+                if (params.pageid) {
+                    // The retake can be reviewed, mark it as finished. Don't block the user for this.
+                    this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId);
+                }
+            }
+
+            await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
+        } catch (error) {
+            if (!error || !CoreUtils.instance.isWebServiceError(error)) {
+                // Couldn't connect to server.
+                throw error;
+            }
+
+            // The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
+            result.updated = true;
+
+            await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
+
+            // Retake deleted, add a warning.
+            result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
+                component: this.componentTranslate,
+                name: passwordData.lesson.name,
+                error: CoreTextUtils.instance.getErrorMessageFromError(error),
+            }));
+        }
+    }
+
+}
+
+export class AddonModLessonSync extends makeSingleton(AddonModLessonSyncProvider) {}
+
+/**
+ * Data returned by a lesson sync.
+ */
+export type AddonModLessonSyncResult = {
+    warnings: string[]; // List of warnings.
+    updated: boolean; // Whether some data was sent to the server or offline data was updated.
+    courseId?: number; // Course the lesson belongs to (if known).
+};
+
+/**
+ * Data passed to AUTO_SYNCED event.
+ */
+export type AddonModLessonAutoSyncData = CoreEventSiteData & {
+    lessonId: number;
+    warnings: string[];
+};