From dc88b83bbb57b40dbfb4d26fff03690133aae8a0 Mon Sep 17 00:00:00 2001
From: Dani Palou <dani@moodle.com>
Date: Fri, 16 Feb 2018 11:34:29 +0100
Subject: [PATCH] MOBILE-2333 siteaddons: Prefetch offlinefunctions

---
 .../mod/book/providers/prefetch-handler.ts    |  16 --
 .../course/classes/module-prefetch-handler.ts |  14 +-
 .../providers/module-prefetch-delegate.ts     |   4 +-
 .../classes/module-prefetch-handler.ts        | 224 ++++++++++++++++++
 src/core/siteaddons/providers/helper.ts       |  19 +-
 src/core/siteaddons/providers/siteaddons.ts   |  70 +++++-
 src/providers/filepool.ts                     |  18 +-
 7 files changed, 331 insertions(+), 34 deletions(-)
 create mode 100644 src/core/siteaddons/classes/module-prefetch-handler.ts

diff --git a/src/addon/mod/book/providers/prefetch-handler.ts b/src/addon/mod/book/providers/prefetch-handler.ts
index ca99a83fc..17aa4b636 100644
--- a/src/addon/mod/book/providers/prefetch-handler.ts
+++ b/src/addon/mod/book/providers/prefetch-handler.ts
@@ -78,22 +78,6 @@ export class AddonModBookPrefetchHandler extends CoreCourseModulePrefetchHandler
         return this.bookProvider.invalidateContent(moduleId, courseId);
     }
 
-    /**
-     * Invalidate WS calls needed to determine module status.
-     *
-     * @param {any} module Module.
-     * @param {number} courseId Course ID the module belongs to.
-     * @return {Promise<any>} Promise resolved when invalidated.
-     */
-    invalidateModule(module: any, courseId: number): Promise<any> {
-        const promises = [];
-
-        promises.push(this.bookProvider.invalidateBookData(courseId));
-        promises.push(this.courseProvider.invalidateModule(module.id));
-
-        return Promise.all(promises);
-    }
-
     /**
      * Whether or not the handler is enabled on a site level.
      *
diff --git a/src/core/course/classes/module-prefetch-handler.ts b/src/core/course/classes/module-prefetch-handler.ts
index 7fde10f08..c078e1b6a 100644
--- a/src/core/course/classes/module-prefetch-handler.ts
+++ b/src/core/course/classes/module-prefetch-handler.ts
@@ -133,10 +133,11 @@ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePref
      *
      * @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<any>} Promise resolved when all content is downloaded.
      */
-    download(module: any, courseId: number): Promise<any> {
-        return this.downloadOrPrefetch(module, courseId, false);
+    download(module: any, courseId: number, dirPath?: string): Promise<any> {
+        return this.downloadOrPrefetch(module, courseId, false, dirPath);
     }
 
     /**
@@ -332,8 +333,8 @@ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePref
     }
 
     /**
-     * Invalidate WS calls needed to determine module status. It doesn't need to invalidate check updates.
-     * It should NOT invalidate files nor all the prefetched data.
+     * Invalidate WS calls needed to determine module status (usually, to check if module is downloadable).
+     * It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data.
      *
      * @param {any} module Module.
      * @param {number} courseId Course ID the module belongs to.
@@ -409,10 +410,11 @@ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePref
      * @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<any>} Promise resolved when done.
      */
-    prefetch(module: any, courseId?: number, single?: boolean): Promise<any> {
-        return this.downloadOrPrefetch(module, courseId, true);
+    prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise<any> {
+        return this.downloadOrPrefetch(module, courseId, true, dirPath);
     }
 
     /**
diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts
index 76589a93e..dce46a02a 100644
--- a/src/core/course/providers/module-prefetch-delegate.ts
+++ b/src/core/course/providers/module-prefetch-delegate.ts
@@ -141,8 +141,8 @@ export interface CoreCourseModulePrefetchHandler extends CoreDelegateHandler {
     hasUpdates?(module: any, courseId: number, moduleUpdates: any[]): boolean | Promise<boolean>;
 
     /**
-     * Invalidate WS calls needed to determine module status. It doesn't need to invalidate check updates.
-     * It should NOT invalidate files nor all the prefetched data.
+     * Invalidate WS calls needed to determine module status (usually, to check if module is downloadable).
+     * It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data.
      *
      * @param {any} module Module.
      * @param {number} courseId Course ID the module belongs to.
diff --git a/src/core/siteaddons/classes/module-prefetch-handler.ts b/src/core/siteaddons/classes/module-prefetch-handler.ts
new file mode 100644
index 000000000..3972f0d56
--- /dev/null
+++ b/src/core/siteaddons/classes/module-prefetch-handler.ts
@@ -0,0 +1,224 @@
+// (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 { Injector } from '@angular/core';
+import { CoreSiteAddonsProvider } from '../../siteaddons/providers/siteaddons';
+import { CoreCourseModulePrefetchHandlerBase } from '../../course/classes/module-prefetch-handler';
+
+/**
+ * Handler to prefetch a site addon.
+ */
+export class CoreSiteAddonsModulePrefetchHandler extends CoreCourseModulePrefetchHandlerBase {
+    protected ROOT_CACHE_KEY = 'CoreSiteAddonsModulePrefetchHandler:';
+
+    constructor(injector: Injector, protected siteAddonsProvider: CoreSiteAddonsProvider, component: string, modName: string,
+            protected handlerSchema: any) {
+        super(injector);
+
+        this.component = component;
+        this.name = modName;
+        this.isResource = handlerSchema.isresource;
+
+        if (handlerSchema.updatesnames) {
+            try {
+                this.updatesNames = new RegExp(handlerSchema.updatesnames);
+            } catch (ex) {
+                // Ignore errors.
+            }
+        }
+    }
+
+    /**
+     * Download or prefetch the content.
+     *
+     * @param {any} module The module object returned by WS.
+     * @param {number} courseId Course ID.
+     * @param {boolean} [prefetch] True to prefetch, false to download right away.
+     * @param {string} [dirPath] Path of the directory where to store all the content files. This is to keep the files
+     *                           relative paths and make the package work in an iframe. Undefined to download the files
+     *                           in the filepool root folder.
+     * @return {Promise<any>} Promise resolved when all content is downloaded. Data returned is not reliable.
+     */
+    downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise<any> {
+        return this.prefetchPackage(module, courseId, false, this.downloadOrPrefetchAddon.bind(this), undefined, prefetch, dirPath);
+    }
+
+    /**
+     * Download or prefetch the addon, downloading the files and calling the needed WS.
+     *
+     * @param {any} module The module object returned by WS.
+     * @param {number} courseId Course ID.
+     * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
+     * @param {string} [siteId] Site ID. If not defined, current site.
+     * @param {boolean} [prefetch] True to prefetch, false to download right away.
+     * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected downloadOrPrefetchAddon(module: any, courseId: number, single?: boolean, siteId?: string, prefetch?: boolean,
+            dirPath?: string): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+
+            const promises = [],
+                args = {
+                    courseid: courseId,
+                    cmid: module.id,
+                    userid: site.getUserId()
+                };
+
+            // Download the files (if any).
+            promises.push(this.downloadOrPrefetchFiles(site.id, module, courseId, prefetch, dirPath));
+
+            // Call all the offline functions.
+            for (const method in this.handlerSchema.offlinefunctions) {
+                if (site.wsAvailable(method)) {
+                    // The method is a WS.
+                    const paramsList = this.handlerSchema.offlinefunctions[method],
+                        cacheKey = this.siteAddonsProvider.getCallWSCacheKey(method, args);
+                    let params = {};
+
+                    if (!paramsList.length) {
+                        // No params defined, send the default ones.
+                        params = args;
+                    } else {
+                        for (const i in paramsList) {
+                            const paramName = paramsList[i];
+
+                            if (typeof args[paramName] != 'undefined') {
+                                params[paramName] = args[paramName];
+                            } else {
+                                // The param is not one of the default ones. Try to calculate the param to use.
+                                const value = this.getDownloadParam(module, courseId, paramName);
+                                if (typeof value != 'undefined') {
+                                    params[paramName] = value;
+                                }
+                            }
+                        }
+                    }
+
+                    promises.push(this.siteAddonsProvider.callWS(method, params, {cacheKey: cacheKey}));
+                } else {
+                    // It's a method to get content.
+                    promises.push(this.siteAddonsProvider.getContent(this.component, method, args));
+                }
+            }
+
+            return Promise.all(promises);
+        });
+    }
+
+    /**
+     * Download or prefetch the addon files.
+     *
+     * @param {any} module The module object returned by WS.
+     * @param {number} courseId Course ID.
+     * @param {boolean} [prefetch] True to prefetch, false to download right away.
+     * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected downloadOrPrefetchFiles(siteId: string, module: any, courseId: number, prefetch?: boolean, dirPath?: string)
+            : Promise<any> {
+        // Load module contents (ignore cache so we always have the latest data).
+        return this.loadContents(module, courseId, true).then(() => {
+            // Get the intro files.
+            return this.getIntroFiles(module, courseId);
+        }).then((introFiles) => {
+            const contentFiles = this.getContentDownloadableFiles(module),
+                promises = [];
+
+            if (dirPath) {
+                // Download intro files in filepool root folder.
+                promises.push(this.filepoolProvider.downloadOrPrefetchFiles(siteId, introFiles, prefetch, false,
+                    this.component, module.id));
+
+                // Download content files inside dirPath.
+                promises.push(this.filepoolProvider.downloadOrPrefetchFiles(siteId, contentFiles, prefetch, false,
+                    this.component, module.id, dirPath));
+            } else {
+                // No dirPath, download everything in filepool root folder.
+                const files = introFiles.concat(contentFiles);
+                promises.push(this.filepoolProvider.downloadOrPrefetchFiles(siteId, files, prefetch, false,
+                    this.component, module.id));
+            }
+
+            return Promise.all(promises);
+        });
+    }
+
+    /**
+     * Get the value of a WS param for prefetch.
+     *
+     * @param {any} module The module object returned by WS.
+     * @param {number} courseId Course ID.
+     * @param {string} paramName Name of the param as defined by the handler.
+     * @return {any} The value.
+     */
+    protected getDownloadParam(module: any, courseId: number, paramName: string): any {
+        switch (paramName) {
+            case 'courseids':
+                // The WS needs the list of course IDs. Create the list.
+                return [courseId];
+
+            case this.component + 'id':
+                // The WS needs the instance id.
+                return module.instance;
+
+            default:
+                // No more params supported for now.
+        }
+    }
+
+    /**
+     * Invalidate the prefetched content.
+     *
+     * @param {number} moduleId The module ID.
+     * @param {number} courseId Course ID the module belongs to.
+     * @return {Promise<any>} Promise resolved when the data is invalidated.
+     */
+    invalidateContent(moduleId: number, courseId: number): Promise<any> {
+        const promises = [],
+            currentSite = this.sitesProvider.getCurrentSite(),
+            siteId = currentSite.getId(),
+            args = {
+                courseid: courseId,
+                cmid: moduleId,
+                userid: currentSite.getUserId()
+            };
+
+        // Invalidate files and the module.
+        promises.push(this.filepoolProvider.invalidateFilesByComponent(siteId, this.component, moduleId));
+        promises.push(this.courseProvider.invalidateModule(moduleId, siteId));
+
+        // Also invalidate all the WS calls.
+        for (const method in this.handlerSchema.offlinefunctions) {
+            if (currentSite.wsAvailable(method)) {
+                // The method is a WS.
+                promises.push(currentSite.invalidateWsCacheForKey(this.siteAddonsProvider.getCallWSCacheKey(method, args)));
+            } else {
+                // It's a method to get content.
+                promises.push(this.siteAddonsProvider.invalidateContent(this.component, method, args));
+            }
+        }
+
+        return this.utils.allPromises(promises);
+    }
+
+    /**
+     * Whether or not the handler is enabled on a site level.
+     *
+     * @return {boolean|Promise<boolean>} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled.
+     */
+    isEnabled(): boolean | Promise<boolean> {
+        return true;
+    }
+}
diff --git a/src/core/siteaddons/providers/helper.ts b/src/core/siteaddons/providers/helper.ts
index 5e326759c..463f5b079 100644
--- a/src/core/siteaddons/providers/helper.ts
+++ b/src/core/siteaddons/providers/helper.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { Injectable } from '@angular/core';
+import { Injectable, Injector } from '@angular/core';
 import { NavController, NavOptions } from 'ionic-angular';
 import { CoreLangProvider } from '../../../providers/lang';
 import { CoreLoggerProvider } from '../../../providers/logger';
@@ -22,10 +22,12 @@ import { CoreMainMenuDelegate, CoreMainMenuHandler, CoreMainMenuHandlerData } fr
 import {
     CoreCourseModuleDelegate, CoreCourseModuleHandler, CoreCourseModuleHandlerData
 } from '../../../core/course/providers/module-delegate';
+import { CoreCourseModulePrefetchDelegate } from '../../../core/course/providers/module-prefetch-delegate';
 import { CoreUserDelegate, CoreUserProfileHandler, CoreUserProfileHandlerData } from '../../../core/user/providers/user-delegate';
 import { CoreDelegateHandler } from '../../../classes/delegate';
 import { CoreSiteAddonsModuleIndexComponent } from '../components/module-index/module-index';
 import { CoreSiteAddonsProvider } from './siteaddons';
+import { CoreSiteAddonsModulePrefetchHandler } from '../classes/module-prefetch-handler';
 
 /**
  * Helper service to provide functionalities regarding site addons. It basically has the features to load and register site
@@ -37,10 +39,10 @@ import { CoreSiteAddonsProvider } from './siteaddons';
 export class CoreSiteAddonsHelperProvider {
     protected logger;
 
-    constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider,
+    constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider,  private injector: Injector,
             private mainMenuDelegate: CoreMainMenuDelegate, private moduleDelegate: CoreCourseModuleDelegate,
             private userDelegate: CoreUserDelegate, private langProvider: CoreLangProvider,
-            private siteAddonsProvider: CoreSiteAddonsProvider) {
+            private siteAddonsProvider: CoreSiteAddonsProvider, private prefetchDelegate: CoreCourseModulePrefetchDelegate) {
         this.logger = logger.getInstance('CoreSiteAddonsHelperProvider');
     }
 
@@ -240,7 +242,8 @@ export class CoreSiteAddonsHelperProvider {
 
         // Create the base handler.
         const modName = addon.component.replace('mod_', ''),
-            baseHandler = this.getBaseHandler(modName);
+            baseHandler = this.getBaseHandler(modName),
+            hasOfflineFunctions = !!(handlerSchema.offlinefunctions && Object.keys(handlerSchema.offlinefunctions).length);
         let moduleHandler: CoreCourseModuleHandler;
 
         // Store the handler data.
@@ -257,7 +260,7 @@ export class CoreSiteAddonsHelperProvider {
                     title: module.name,
                     icon: handlerSchema.displaydata.icon,
                     class: handlerSchema.displaydata.class,
-                    showDownloadButton: handlerSchema.offlinefunctions && handlerSchema.offlinefunctions.length,
+                    showDownloadButton: hasOfflineFunctions,
                     action: (event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void => {
                         event.preventDefault();
                         event.stopPropagation();
@@ -279,6 +282,12 @@ export class CoreSiteAddonsHelperProvider {
             }
         });
 
+        if (hasOfflineFunctions) {
+            // Register the prefetch handler.
+            this.prefetchDelegate.registerHandler(new CoreSiteAddonsModulePrefetchHandler(
+                this.injector, this.siteAddonsProvider, addon.component, modName, handlerSchema));
+        }
+
         this.moduleDelegate.registerHandler(moduleHandler);
     }
 
diff --git a/src/core/siteaddons/providers/siteaddons.ts b/src/core/siteaddons/providers/siteaddons.ts
index f4b4a0ea5..fdd3dff6d 100644
--- a/src/core/siteaddons/providers/siteaddons.ts
+++ b/src/core/siteaddons/providers/siteaddons.ts
@@ -15,7 +15,7 @@
 import { Injectable } from '@angular/core';
 import { CoreLangProvider } from '../../../providers/lang';
 import { CoreLoggerProvider } from '../../../providers/logger';
-import { CoreSite } from '../../../classes/site';
+import { CoreSite, CoreSiteWSPreSets } from '../../../classes/site';
 import { CoreSitesProvider } from '../../../providers/sites';
 import { CoreUtilsProvider } from '../../../providers/utils/utils';
 import { CoreConfigConstants } from '../../../configconstants';
@@ -58,6 +58,45 @@ export class CoreSiteAddonsProvider {
         this.logger = logger.getInstance('CoreUserProvider');
     }
 
+    /**
+     * Call a WS for a site addon.
+     *
+     * @param {string} method WS method to use.
+     * @param {any} data Data to send to the WS.
+     * @param {CoreSiteWSPreSets} [preSets] Extra options.
+     * @param {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>} Promise resolved with the response.
+     */
+    callWS(method: string, data: any, preSets?: CoreSiteWSPreSets, siteId?: string): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            preSets = preSets || {};
+            preSets.cacheKey = preSets.cacheKey || this.getCallWSCacheKey(method, data);
+
+            return site.read(method, data, preSets);
+        });
+    }
+
+    /**
+     * Get cache key for a WS call.
+     *
+     * @param {string} method Name of the method.
+     * @param {any} data Data to identify the WS call.
+     * @return {string} Cache key.
+     */
+    getCallWSCacheKey(method: string, data: any): string {
+        return this.getCallWSCommonCacheKey(method) + ':' + this.utils.sortAndStringify(data);
+    }
+
+    /**
+     * Get common cache key for a WS call.
+     *
+     * @param {string} method Name of the method.
+     * @return {string} Cache key.
+     */
+    protected getCallWSCommonCacheKey(method: string): string {
+        return this.ROOT_CACHE_KEY + method;
+    }
+
     /**
      * Get a certain content for a site addon.
      *
@@ -103,7 +142,7 @@ export class CoreSiteAddonsProvider {
      * @return {string} Cache key.
      */
     protected getContentCacheKey(component: string, method: string, args: any): string {
-        return this.ROOT_CACHE_KEY + 'content:' + component + ':' + method + ':' + JSON.stringify(args);
+        return this.ROOT_CACHE_KEY + 'content:' + component + ':' + method + ':' + this.utils.sortAndStringify(args);
     }
 
     /**
@@ -116,6 +155,33 @@ export class CoreSiteAddonsProvider {
         return this.moduleSiteAddons[modName];
     }
 
+    /**
+     * Invalidate all WS call to a certain method.
+     *
+     * @param {string} method WS method to use.
+     * @param {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>} Promise resolved when the data is invalidated.
+     */
+    invalidateAllCallWSForMethod(method: string, siteId?: string): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.invalidateWsCacheForKeyStartingWith(this.getCallWSCommonCacheKey(method));
+        });
+    }
+
+    /**
+     * Invalidate a WS call.
+     *
+     * @param {string} method WS method to use.
+     * @param {any} data Data to send to the WS.
+     * @param {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>} Promise resolved when the data is invalidated.
+     */
+    invalidateCallWS(method: string, data: any, siteId?: string): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.invalidateWsCacheForKey(this.getCallWSCacheKey(method, data));
+        });
+    }
+
     /**
      * Invalidate a page content.
      *
diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts
index cc677fef9..259ba617f 100644
--- a/src/providers/filepool.ts
+++ b/src/providers/filepool.ts
@@ -961,10 +961,12 @@ export class CoreFilepoolProvider {
      * @param {boolean} [ignoreStale] True if 'stale' should be ignored. Only if prefetch=false.
      * @param {string} [component] The component to link the file to.
      * @param {string|number} [componentId] An ID to use in conjunction with the component.
+     * @param {string} [dirPath] Name of the directory where to store the files (inside filepool dir). If not defined, store
+     *                           the files directly inside the filepool folder.
      * @return {Promise<any>} Resolved on success.
      */
     downloadOrPrefetchFiles(siteId: string, files: any[], prefetch: boolean, ignoreStale?: boolean, component?: string,
-            componentId?: string | number): Promise<any> {
+            componentId?: string | number, dirPath?: string): Promise<any> {
         const promises = [];
 
         // Download files.
@@ -975,13 +977,23 @@ export class CoreFilepoolProvider {
                     isexternalfile: file.isexternalfile,
                     repositorytype: file.repositorytype
                 };
+            let path;
+
+            if (dirPath) {
+                // Calculate the path to the file.
+                path = file.filename;
+                if (file.filepath !== '/') {
+                    path = file.filepath.substr(1) + path;
+                }
+                path = this.textUtils.concatenatePaths(dirPath, path);
+            }
 
             if (prefetch) {
                 promises.push(this.addToQueueByUrl(
-                    siteId, url, component, componentId, timemodified, undefined, undefined, 0, options));
+                    siteId, url, component, componentId, timemodified, path, undefined, 0, options));
             } else {
                 promises.push(this.downloadUrl(
-                    siteId, url, ignoreStale, component, componentId, timemodified, undefined, undefined, options));
+                    siteId, url, ignoreStale, component, componentId, timemodified, path, undefined, options));
             }
         });