diff --git a/src/app/app.module.ts b/src/app/app.module.ts index c57caa521..e9652a81a 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -45,6 +45,7 @@ import { CoreSitesFactoryProvider } from '../providers/sites-factory'; import { CoreSitesProvider } from '../providers/sites'; import { CoreLocalNotificationsProvider } from '../providers/local-notifications'; import { CoreGroupsProvider } from '../providers/groups'; +import { CoreCronDelegate } from '../providers/cron'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -97,6 +98,7 @@ export function createTranslateLoader(http: HttpClient) { CoreSitesProvider, CoreLocalNotificationsProvider, CoreGroupsProvider, + CoreCronDelegate, ] }) export class AppModule { diff --git a/src/core/constants.ts b/src/core/constants.ts index f73227031..8f791a125 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -27,6 +27,7 @@ export class CoreConstants { // Settings constants. public static settingsRichTextEditor = 'CoreSettingsRichTextEditor'; public static settingsNotificationSound = 'CoreSettingsNotificationSound'; + public static settingsSyncOnlyOnWifi = 'mmCoreSyncOnlyOnWifi'; // WS constants. public static wsTimeout = 30000; diff --git a/src/providers/cron.ts b/src/providers/cron.ts new file mode 100644 index 000000000..c6605700f --- /dev/null +++ b/src/providers/cron.ts @@ -0,0 +1,463 @@ +// (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 { Network } from '@ionic-native/network'; +import { CoreAppProvider } from './app'; +import { CoreConfigProvider } from './config'; +import { CoreLoggerProvider } from './logger'; +import { CoreUtilsProvider } from './utils/utils'; +import { CoreConstants } from '../core/constants'; +import { SQLiteDB } from '../classes/sqlitedb'; + +export interface CoreCronHandler { + name: string; // Handler's name. + getInterval?(): number; // Returns handler's interval in milliseconds. Defaults to CoreCronDelegate.DEFAULT_INTERVAL. + usesNetwork?(): boolean; // Whether the process uses network or not. True if not defined. + isSync?(): boolean; // Whether it's a synchronization process or not. True if not defined. + canManualSync?(): boolean; // Whether the sync can be executed manually. Call isSync if not defined. + execute?(siteId?: string): Promise; // Execute the process. Receives ID of site affected, undefined for all sites. + // Important: If the promise is rejected then this function will be called again + // often, it shouldn't be abused. + running: boolean; // Whether the handler is running. Used internally by the provider, there's no need to set it. + timeout: number; // Timeout ID for the handler scheduling. Used internally by the provider, there's no need to set it. +}; + +/* + * Service to handle cron processes. The registered processes will be executed every certain time. +*/ +@Injectable() +export class CoreCronDelegate { + // Constants. + public static DEFAULT_INTERVAL = 3600000; // Default interval is 1 hour. + public static MIN_INTERVAL = 300000; // Minimum interval is 5 minutes. + public static DESKTOP_MIN_INTERVAL = 60000; // Minimum interval in desktop is 1 minute. + public static MAX_TIME_PROCESS = 120000; // Max time a process can block the queue. Defaults to 2 minutes. + + // Variables for database. + protected CRON_TABLE = 'cron'; + protected tableSchema = { + name: this.CRON_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'value', + type: 'INTEGER' + } + ] + }; + + protected logger; + protected appDB: SQLiteDB; + protected handlers: {[s: string]: CoreCronHandler} = {}; + protected queuePromise = Promise.resolve(); + + constructor(logger: CoreLoggerProvider, private appProvider: CoreAppProvider, private configProvider: CoreConfigProvider, + private utils: CoreUtilsProvider, network: Network) { + this.logger = logger.getInstance('CoreCronDelegate'); + + this.appDB = this.appProvider.getDB(); + this.appDB.createTableFromSchema(this.tableSchema); + + // When the app is re-connected, start network handlers that were stopped. + network.onConnect().subscribe(() => { + this.startNetworkHandlers(); + }); + } + + /** + * Try to execute a handler. It will schedule the next execution once done. + * If the handler cannot be executed or it fails, it will be re-executed after mmCoreCronMinInterval. + * + * @param {string} name Name of the handler. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @param {string} [siteId] Site ID. If not defined, all sites. + * @return {Promise} Promise resolved if handler is executed successfully, rejected otherwise. + */ + protected checkAndExecuteHandler(name: string, force?: boolean, siteId?: string) : Promise { + if (!this.handlers[name] || !this.handlers[name].execute) { + // Invalid handler. + this.logger.debug('Cannot execute handler because is invalid: ' + name); + return Promise.reject(null); + } + + let usesNetwork = this.handlerUsesNetwork(name), + isSync = !force && this.isHandlerSync(name), + promise; + + if (usesNetwork && !this.appProvider.isOnline()) { + // Offline, stop executing. + this.logger.debug('Cannot execute handler because device is offline: ' + name); + this.stopHandler(name); + return Promise.reject(null); + } + + if (isSync) { + // Check network connection. + promise = this.configProvider.get(CoreConstants.settingsSyncOnlyOnWifi, false).then((syncOnlyOnWifi) => { + return !syncOnlyOnWifi || !this.appProvider.isNetworkAccessLimited(); + }); + } else { + promise = Promise.resolve(true); + } + + return promise.then((execute: boolean) => { + if (!execute) { + // Cannot execute in this network connection, retry soon. + this.logger.debug('Cannot execute handler because device is using limited connection: ' + name); + this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); + return Promise.reject(null); + } + + // Add the execution to the queue. + this.queuePromise = this.queuePromise.catch(() => { + // Ignore errors in previous handlers. + }).then(() => { + return this.executeHandler(name, siteId).then(() => { + this.logger.debug(`Execution of handler '${name}' was a success.`); + return this.setHandlerLastExecutionTime(name, Date.now()).then(() => { + this.scheduleNextExecution(name); + }); + }, () => { + // Handler call failed. Retry soon. + this.logger.debug(`Execution of handler '${name}' failed.`); + this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); + return Promise.reject(null); + }); + }); + + return this.queuePromise; + }); + } + + /** + * Run a handler, cancelling the execution if it takes more than MAX_TIME_PROCESS. + * + * @param {string} name Name of the handler. + * @param {string} [siteId] Site ID. If not defined, all sites. + * @return {Promise} Promise resolved when the handler finishes or reaches max time, rejected if it fails. + */ + protected executeHandler(name: string, siteId?: string) : Promise { + return new Promise((resolve, reject) => { + let cancelTimeout; + + this.logger.debug('Executing handler: ' + name); + // Wrap the call in Promise.resolve to make sure it's a promise. + Promise.resolve(this.handlers[name].execute(siteId)).then(resolve).catch(reject).finally(() => { + clearTimeout(cancelTimeout); + }); + + cancelTimeout = setTimeout(() => { + // The handler took too long. Resolve because we don't want to retry soon. + this.logger.debug(`Resolving execution of handler '${name}' because it took too long.`); + resolve(); + }, CoreCronDelegate.MAX_TIME_PROCESS); + }); + } + + /** + * Force execution of synchronization cron tasks without waiting for the scheduled time. + * Please notice that some tasks may not be executed depending on the network connection and sync settings. + * + * @param {string} [siteId] Site ID. If not defined, all sites. + * @return {Promise} Promise resolved if all handlers are executed successfully, rejected otherwise. + */ + forceSyncExecution(siteId?: string) : Promise { + let promises = []; + + for (let name in this.handlers) { + let handler = this.handlers[name]; + if (this.isHandlerManualSync(name)) { + // Mark the handler as running (it might be running already). + handler.running = true; + + // Cancel pending timeout. + clearTimeout(handler.timeout); + delete handler.timeout; + + // Now force the execution of the handler. + promises.push(this.checkAndExecuteHandler(name, true, siteId)); + } + } + + return this.utils.allPromises(promises); + } + + /** + * Get a handler's interval. + * + * @param {string} name Handler's name. + * @return {number} Handler's interval. + */ + protected getHandlerInterval(name) : number { + if (!this.handlers[name] || !this.handlers[name].getInterval) { + // Invalid, return default. + return CoreCronDelegate.DEFAULT_INTERVAL; + } + + // Don't allow intervals lower than the minimum. + const minInterval = this.appProvider.isDesktop() ? CoreCronDelegate.DESKTOP_MIN_INTERVAL : CoreCronDelegate.MIN_INTERVAL, + handlerInterval = this.handlers[name].getInterval(); + + if (!handlerInterval) { + return CoreCronDelegate.DEFAULT_INTERVAL; + } else { + return Math.max(minInterval, handlerInterval); + } + } + + /** + * Get a handler's last execution ID. + * + * @param {string} name Handler's name. + * @return {string} Handler's last execution ID. + */ + protected getHandlerLastExecutionId(name: string) : string { + return 'last_execution_' + name; + } + + /** + * Get a handler's last execution time. If not defined, return 0. + * + * @param {string} name Handler's name. + * @return {Promise} Promise resolved with the handler's last execution time. + */ + protected getHandlerLastExecutionTime(name: string) : Promise { + const id = this.getHandlerLastExecutionId(name); + return this.appDB.getRecord(this.CRON_TABLE, {id: id}).then((entry) => { + const time = parseInt(entry.value, 10); + return isNaN(time) ? 0 : time; + }).catch(() => { + return 0; // Not set, return 0. + }); + } + + /** + * Check if a handler uses network. Defaults to true. + * + * @param {string} name Handler's name. + * @return {boolean} True if handler uses network or not defined, false otherwise. + */ + protected handlerUsesNetwork(name: string) : boolean { + if (!this.handlers[name] || !this.handlers[name].usesNetwork) { + // Invalid, return default. + return true; + } + + return this.handlers[name].usesNetwork(); + } + + /** + * Check if there is any manual sync handler registered. + * + * @return {boolean} Whether it has at least 1 manual sync handler. + */ + hasManualSyncHandlers() : boolean { + for (let name in this.handlers) { + if (this.isHandlerManualSync(name)) { + return true; + } + } + return false; + } + + /** + * Check if there is any sync handler registered. + * + * @return {boolean} Whether it has at least 1 sync handler. + */ + hasSyncHandlers() : boolean { + for (let name in this.handlers) { + if (this.isHandlerSync(name)) { + return true; + } + } + return false; + } + + /** + * Check if a handler can be manually synced. Defaults will use isSync instead. + * + * @param {string} name Handler's name. + * @return {boolean} True if handler is a sync process and can be manually executed or not defined, false otherwise. + */ + protected isHandlerManualSync(name: string) : boolean { + if (!this.handlers[name] || !this.handlers[name].canManualSync) { + // Invalid, return default. + return this.isHandlerSync(name); + } + + return this.handlers[name].canManualSync(); + } + + /** + * Check if a handler is a sync process. Defaults to true. + * + * @param {string} name Handler's name. + * @return {boolean} True if handler is a sync process or not defined, false otherwise. + */ + protected isHandlerSync(name) : boolean { + if (!this.handlers[name] || !this.handlers[name].isSync) { + // Invalid, return default. + return true; + } + + return this.handlers[name].isSync(); + } + + /** + * Register a handler to be executed every certain time. + * + * @param {CoreCronHandler} handler The handler to register. + */ + register(handler: CoreCronHandler) : void { + if (!handler || !handler.name) { + // Invalid handler. + return; + } + if (typeof this.handlers[handler.name] != 'undefined') { + this.logger.debug(`The cron handler '${name}' is already registered.`); + return; + } + + this.logger.debug.debug(`Register handler '${name}' in cron.`); + + handler.running = false; + this.handlers[handler.name] = handler; + + // Start the handler. + this.startHandler(name); + } + + /** + * Schedule a next execution for a handler. + * + * @param {string} name Name of the handler. + * @param {number} [time] Time to the next execution. If not supplied it will be calculated using the last execution and + * the handler's interval. This param should be used only if it's really necessary. + */ + protected scheduleNextExecution(name: string, time?: number) : void { + if (!this.handlers[name]) { + // Invalid handler. + return; + } + if (this.handlers[name].timeout) { + // There's already a pending timeout. + return; + } + + let promise; + + if (time) { + promise = Promise.resolve(time); + } else { + // Get last execution time to check when do we need to execute it. + promise = this.getHandlerLastExecutionTime(name).then((lastExecution) => { + const interval = this.getHandlerInterval(name), + nextExecution = lastExecution + interval; + + return nextExecution - Date.now(); + }); + } + + promise.then((nextExecution) => { + this.logger.debug(`Scheduling next execution of handler '${name}' in '${nextExecution}' ms`); + if (nextExecution < 0) { + nextExecution = 0; // Big negative numbers aren't executed immediately. + } + + this.handlers[name].timeout = setTimeout(() => { + delete this.handlers[name].timeout; + this.checkAndExecuteHandler(name); + }, nextExecution); + }); + } + + /** + * Set a handler's last execution time. + * + * @param {string} name Handler's name. + * @param {number} time Time to set. + * @return {Promise} Promise resolved when the execution time is saved. + */ + protected setHandlerLastExecutionTime(name: string, time: number) : Promise { + const id = this.getHandlerLastExecutionId(name), + entry = { + id: id, + value: time + } + + return this.appDB.insertOrUpdateRecord(this.CRON_TABLE, entry, {id: id}); + } + + /** + * Start running a handler periodically. + * + * @param {string} name Name of the handler. + */ + protected startHandler(name) : void { + if (!this.handlers[name]) { + // Invalid handler. + this.logger.debug(`Cannot start handler '${name}', is invalid.`); + return; + } + + if (this.handlers[name].running) { + this.logger.debug(`Handler '${name}', is already running.`); + return; + } + + this.handlers[name].running = true; + + this.scheduleNextExecution(name); + } + + /** + * Start running periodically the handlers that use network. + */ + startNetworkHandlers() : void { + for (let name in this.handlers) { + if (this.handlerUsesNetwork(name)) { + this.startHandler(name); + } + } + } + + /** + * Stop running a handler periodically. + * + * @param {string} name Name of the handler. + */ + protected stopHandler(name) { + if (!this.handlers[name]) { + // Invalid handler. + this.logger.debug(`Cannot stop handler '${name}', is invalid.`); + return; + } + + if (!this.handlers[name].running) { + this.logger.debug(`Cannot stop handler '${name}', it's not running.`); + return; + } + + this.handlers[name].running = false; + clearTimeout(this.handlers[name].timeout); + delete this.handlers[name].timeout; + } + +}