diff --git a/src/core/classes/base-sync.ts b/src/core/classes/base-sync.ts new file mode 100644 index 000000000..4f7c1b63f --- /dev/null +++ b/src/core/classes/base-sync.ts @@ -0,0 +1,307 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { Translate } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreError } from '@classes/errors/error'; + +/** + * Blocked sync error. + */ +export class CoreSyncBlockedError extends CoreError {} + +/** + * Base class to create sync providers. It provides some common functions. + */ +export class CoreSyncBaseProvider { + + /** + * Logger instance. + */ + protected logger: CoreLogger; + + /** + * Component of the sync provider. + */ + component = 'core'; + + /** + * Sync provider's interval. + */ + syncInterval = 300000; + + // Store sync promises. + protected syncPromises: { [siteId: string]: { [uniqueId: string]: Promise } } = {}; + + constructor(component: string) { + this.logger = CoreLogger.getInstance(component); + this.component = component; + } + + /** + * Add an offline data deleted warning to a list of warnings. + * + * @param warnings List of warnings. + * @param component Component. + * @param name Instance name. + * @param error Specific error message. + */ + protected addOfflineDataDeletedWarning(warnings: string[], component: string, name: string, error: string): void { + const warning = Translate.instance.instant('core.warningofflinedatadeleted', { + component: component, + name: name, + error: error, + }); + + if (warnings.indexOf(warning) == -1) { + warnings.push(warning); + } + } + + /** + * Add an ongoing sync to the syncPromises list. On finish the promise will be removed. + * + * @param id Unique sync identifier per component. + * @param promise The promise of the sync to add. + * @param siteId Site ID. If not defined, current site. + * @return The sync promise. + */ + async addOngoingSync(id: string | number, promise: Promise, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + throw new CoreError('CoreSyncBaseProvider: Site ID not supplied'); + } + + const uniqueId = this.getUniqueSyncId(id); + if (!this.syncPromises[siteId]) { + this.syncPromises[siteId] = {}; + } + + this.syncPromises[siteId][uniqueId] = promise; + + // Promise will be deleted when finish. + try { + return await promise; + } finally { + delete this.syncPromises[siteId!][uniqueId]; + } + } + + /** + * If there's an ongoing sync for a certain identifier return it. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise of the current sync or undefined if there isn't any. + */ + getOngoingSync(id: string | number, siteId?: string): Promise | undefined { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!this.isSyncing(id, siteId)) { + return; + } + + // There's already a sync ongoing for this id, return the promise. + const uniqueId = this.getUniqueSyncId(id); + + return this.syncPromises[siteId][uniqueId]; + } + + /** + * Get the synchronization time in a human readable format. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the readable time. + */ + async getReadableSyncTime(id: string | number, siteId?: string): Promise { + const time = await this.getSyncTime(id, siteId); + + return this.getReadableTimeFromTimestamp(time); + } + + /** + * Given a timestamp return it in a human readable format. + * + * @param timestamp Timestamp + * @return Human readable time. + */ + getReadableTimeFromTimestamp(timestamp: number): string { + if (!timestamp) { + return Translate.instance.instant('core.never'); + } else { + return CoreTimeUtils.instance.userDate(timestamp); + } + } + + /** + * Get the synchronization time. Returns 0 if no time stored. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the time. + */ + async getSyncTime(id: string | number, siteId?: string): Promise { + try { + const entry = await CoreSync.instance.getSyncRecord(this.component, id, siteId); + + return entry.time; + } catch { + return 0; + } + } + + /** + * Get the synchronization warnings of an instance. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the warnings. + */ + async getSyncWarnings(id: string | number, siteId?: string): Promise { + try { + const entry = await CoreSync.instance.getSyncRecord(this.component, id, siteId); + + return CoreTextUtils.instance.parseJSON(entry.warnings, []); + } catch { + return []; + } + } + + /** + * Create a unique identifier from component and id. + * + * @param id Unique sync identifier per component. + * @return Unique identifier from component and id. + */ + protected getUniqueSyncId(id: string | number): string { + return this.component + '#' + id; + } + + /** + * Check if a there's an ongoing syncronization for the given id. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Whether it's synchronizing. + */ + isSyncing(id: string | number, siteId?: string): boolean { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const uniqueId = this.getUniqueSyncId(id); + + return !!(this.syncPromises[siteId] && this.syncPromises[siteId][uniqueId]); + } + + /** + * Check if a sync is needed: if a certain time has passed since the last time. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether sync is needed. + */ + async isSyncNeeded(id: string | number, siteId?: string): Promise { + const time = await this.getSyncTime(id, siteId); + + return Date.now() - this.syncInterval >= time; + } + + /** + * Set the synchronization time. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @param time Time to set. If not defined, current time. + * @return Promise resolved when the time is set. + */ + async setSyncTime(id: string, siteId?: string, time?: number): Promise { + time = typeof time != 'undefined' ? time : Date.now(); + + await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { time: time }, siteId); + } + + /** + * Set the synchronization warnings. + * + * @param id Unique sync identifier per component. + * @param warnings Warnings to set. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async setSyncWarnings(id: string, warnings: string[], siteId?: string): Promise { + const warningsText = JSON.stringify(warnings || []); + + await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { warnings: warningsText }, siteId); + } + + /** + * Execute a sync function on selected sites. + * + * @param syncFunctionLog Log message to explain the sync function purpose. + * @param syncFunction Sync function to execute. + * @param siteId Site ID to sync. If not defined, sync all sites. + * @return Resolved with siteIds selected. Rejected if offline. + */ + async syncOnSites(syncFunctionLog: string, syncFunction: (siteId: string) => void, siteId?: string): Promise { + if (!CoreApp.instance.isOnline()) { + const message = `Cannot sync '${syncFunctionLog}' because device is offline.`; + this.logger.debug(message); + + throw new CoreError(message); + } + + let siteIds: string[] = []; + + if (!siteId) { + // No site ID defined, sync all sites. + this.logger.debug(`Try to sync '${syncFunctionLog}' in all sites.`); + siteIds = await CoreSites.instance.getLoggedInSitesIds(); + } else { + this.logger.debug(`Try to sync '${syncFunctionLog}' in site '${siteId}'.`); + siteIds = [siteId]; + } + + // Execute function for every site. + await Promise.all(siteIds.map((siteId) => syncFunction(siteId))); + } + + /** + * If there's an ongoing sync for a certain identifier, wait for it to end. + * If there's no sync ongoing the promise will be resolved right away. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when there's no sync going on for the identifier. + */ + async waitForSync(id: string | number, siteId?: string): Promise { + const promise = this.getOngoingSync(id, siteId); + + if (!promise) { + return; + } + + try { + return await promise; + } catch { + return; + } + } + +} diff --git a/src/core/features/user/services/handlers/sync-cron.ts b/src/core/features/user/services/handlers/sync-cron.ts new file mode 100644 index 000000000..96a99cc17 --- /dev/null +++ b/src/core/features/user/services/handlers/sync-cron.ts @@ -0,0 +1,50 @@ +// (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 { CoreUserSync } from '../user-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserSyncCronHandler implements CoreCronHandler { + + name = 'CoreUserSyncCronHandler'; + + /** + * 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. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + execute(siteId?: string, force?: boolean): Promise { + return CoreUserSync.instance.syncPreferences(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } + +} diff --git a/src/core/features/user/services/user-sync.ts b/src/core/features/user/services/user-sync.ts new file mode 100644 index 000000000..644c920ec --- /dev/null +++ b/src/core/features/user/services/user-sync.ts @@ -0,0 +1,107 @@ +// (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 } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { makeSingleton } from '@singletons'; +import { CoreUserOffline } from './user-offline'; +import { CoreUser } from './user'; + +/** + * Service to sync user preferences. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'core_user_autom_synced'; + + constructor() { + super('CoreUserSync'); + } + + /** + * Try to synchronize user preferences in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @return Promise resolved with warnings if sync is successful, rejected if sync fails. + */ + syncPreferences(siteId?: string): Promise { + return this.syncOnSites('all user preferences', this.syncSitePreferences.bind(this), siteId); + } + + /** + * Sync user preferences of a site. + * + * @param siteId Site ID to sync. + * @param Promise resolved with warnings if sync is successful, rejected if sync fails. + */ + async syncSitePreferences(siteId: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const syncId = 'preferences'; + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing, return the promise. + return this.getOngoingSync(syncId, siteId)!; + } + + this.logger.debug('Try to sync user preferences'); + + const syncPromise = this.performSyncSitePreferences(siteId); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Sync user preferences of a site. + * + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async performSyncSitePreferences(siteId: string): Promise { + const warnings: string[] = []; + + const preferences = await CoreUserOffline.instance.getChangedPreferences(siteId); + + await CoreUtils.instance.allPromises(preferences.map(async (preference) => { + const onlineValue = await CoreUser.instance.getUserPreferenceOnline(preference.name, siteId); + + if (onlineValue !== null && preference.onlinevalue != onlineValue) { + // Preference was changed on web while the app was offline, do not sync. + return CoreUserOffline.instance.setPreference(preference.name, onlineValue, onlineValue, siteId); + } + + try { + await CoreUser.instance.setUserPreference(preference.name, preference.value, siteId); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + warnings.push(CoreTextUtils.instance.getErrorMessageFromError(error)!); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + })); + + // All done, return the warnings. + return warnings; + } + +} + +export class CoreUserSync extends makeSingleton(CoreUserSyncProvider) {} diff --git a/src/core/features/user/user.module.ts b/src/core/features/user/user.module.ts index 6c580f42d..1a7190632 100644 --- a/src/core/features/user/user.module.ts +++ b/src/core/features/user/user.module.ts @@ -41,6 +41,13 @@ const routes: Routes = [ ], multi: true, }, + // { @todo: Uncomment when the init process has been fixed. + // provide: APP_INITIALIZER, + // multi: true, + // deps: [CoreCronDelegate, CoreUserSyncCronHandler], + // useFactory: (cronDelegate: CoreCronDelegate, syncHandler: CoreUserSyncCronHandler) => + // () => cronDelegate.register(syncHandler), + // }, ], }) export class CoreUserModule {} diff --git a/src/core/services/sync.ts b/src/core/services/sync.ts index 7c3c7ca22..84a22e8ef 100644 --- a/src/core/services/sync.ts +++ b/src/core/services/sync.ts @@ -112,7 +112,7 @@ export class CoreSyncProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with done. */ - async insertOrUpdateSyncRecord(component: string, id: string, data: CoreSyncRecord, siteId?: string): Promise { + async insertOrUpdateSyncRecord(component: string, id: string, data: Partial, siteId?: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); data.component = component;