From 8b9635e6757cc188fdc94c7f71a102b5ea94ba3e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 26 Mar 2020 15:09:42 +0100 Subject: [PATCH] MOBILE-3101 core: Install and use cordova-plugin-advanced-http --- config.xml | 1 + package-lock.json | 10 ++ package.json | 7 +- src/classes/native-to-angular-http.ts | 98 +++++++++++++ src/classes/site.ts | 90 ++++++------ src/providers/ws.ts | 196 +++++++++++++++++++++++--- 6 files changed, 340 insertions(+), 62 deletions(-) create mode 100644 src/classes/native-to-angular-http.ts diff --git a/config.xml b/config.xml index a9d1ff306..fc0883b59 100644 --- a/config.xml +++ b/config.xml @@ -182,6 +182,7 @@ + diff --git a/package-lock.json b/package-lock.json index 36872c57b..07c1ea7d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -147,6 +147,11 @@ "resolved": "https://registry.npmjs.org/@ionic-native/globalization/-/globalization-4.20.0.tgz", "integrity": "sha512-zyxaW+vZb1OHeDgGbrZHQe3hy30K4YeKjGr8KNGcwq+k2ZHkfqo/H6XIwf2m/UlFTgacvdR9XZtfP+6N0suybg==" }, + "@ionic-native/http": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@ionic-native/http/-/http-4.20.0.tgz", + "integrity": "sha512-DF+Y1oYoHTv9Y22a2jLgniOmj9Twba+9j8rzHA4xboVT2HpB6bsBSWOktdAXDVjoajXiLsA/u7fh6YD8//NVGg==" + }, "@ionic-native/in-app-browser": { "version": "4.20.0", "resolved": "https://registry.npmjs.org/@ionic-native/in-app-browser/-/in-app-browser-4.20.0.tgz", @@ -2621,6 +2626,11 @@ } } }, + "cordova-plugin-advanced-http": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/cordova-plugin-advanced-http/-/cordova-plugin-advanced-http-2.4.1.tgz", + "integrity": "sha512-6G8MTy/d02jE6n3Y9CVyCtD5hZGiBb+/dR2AIzhKN1RGGz38g1D2C8yE4MqHRvnmry6k/KHQWT1MsHNXrjouXQ==" + }, "cordova-plugin-badge": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/cordova-plugin-badge/-/cordova-plugin-badge-0.8.8.tgz", diff --git a/package.json b/package.json index 2d3f35c34..ecbd217aa 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@ionic-native/file-transfer": "4.20.0", "@ionic-native/geolocation": "4.20.0", "@ionic-native/globalization": "4.20.0", + "@ionic-native/http": "^4.20.0", "@ionic-native/in-app-browser": "4.20.0", "@ionic-native/keyboard": "4.20.0", "@ionic-native/local-notifications": "4.20.0", @@ -81,6 +82,7 @@ "cordova-android-support-gradle-release": "3.0.1", "cordova-clipboard": "1.3.0", "cordova-ios": "5.1.1", + "cordova-plugin-advanced-http": "2.4.1", "cordova-plugin-badge": "0.8.8", "cordova-plugin-camera": "4.1.0", "cordova-plugin-customurlscheme": "5.0.0", @@ -187,7 +189,10 @@ "cordova-plugin-geolocation": { "GEOLOCATION_USAGE_DESCRIPTION": "To locate you" }, - "cordova-plugin-ionic-webview": {} + "cordova-plugin-ionic-webview": {}, + "cordova-plugin-advanced-http": { + "OKHTTP_VERSION": "3.10.0" + } } }, "main": "desktop/electron.js", diff --git a/src/classes/native-to-angular-http.ts b/src/classes/native-to-angular-http.ts new file mode 100644 index 000000000..e21e7eb7e --- /dev/null +++ b/src/classes/native-to-angular-http.ts @@ -0,0 +1,98 @@ +// (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 { HttpResponse as AngularHttpResponse, HttpHeaders } from '@angular/common/http'; +import { HTTPResponse as NativeHttpResponse } from '@ionic-native/http'; + +const HTTP_STATUS_MESSAGES = { + 100: 'Continue', + 101: 'Switching Protocol', + 102: 'Processing', + 103: 'Early Hints', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 207: 'Multi-Status', + 208: 'Already Reported', + 226: 'IM Used', + 300: 'Multiple Choice', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 306: 'unused', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Payload Too Large', + 414: 'URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Range Not Satisfiable', + 417: 'Expectation Failed', + 418: 'I\'m a teapot', + 421: 'Misdirected Request', + 422: 'Unprocessable Entity', + 423: 'Locked', + 424: 'Failed Dependency', + 425: 'Too Early', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 451: 'Unavailable For Legal Reasons', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', + 507: 'Insufficient Storage', + 508: 'Loop Detected', + 510: 'Not Extended', + 511: 'Network Authentication Required', +}; + +/** + * Class that adapts a Cordova plugin http response to an Angular http response. + */ +export class CoreNativeToAngularHttpResponse extends AngularHttpResponse { + + constructor(protected nativeResponse: NativeHttpResponse) { + super({ + body: nativeResponse.data, + headers: new HttpHeaders(nativeResponse.headers), + status: nativeResponse.status, + statusText: HTTP_STATUS_MESSAGES[nativeResponse.status] || '', + url: nativeResponse.url || '' + }); + } +} diff --git a/src/classes/site.ts b/src/classes/site.ts index 54b2dd294..befacd29e 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -14,7 +14,6 @@ import { Injector } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { HttpClient } from '@angular/common/http'; import { SQLiteDB } from './sqlitedb'; import { CoreAppProvider } from '@providers/app'; import { CoreDbProvider } from '@providers/db'; @@ -190,7 +189,6 @@ export class CoreSite { protected domUtils: CoreDomUtilsProvider; protected eventsProvider: CoreEventsProvider; protected fileProvider: CoreFileProvider; - protected http: HttpClient; protected textUtils: CoreTextUtilsProvider; protected timeUtils: CoreTimeUtilsProvider; protected translate: TranslateService; @@ -256,7 +254,6 @@ export class CoreSite { this.domUtils = injector.get(CoreDomUtilsProvider); this.eventsProvider = injector.get(CoreEventsProvider); this.fileProvider = injector.get(CoreFileProvider); - this.http = injector.get(HttpClient); this.textUtils = injector.get(CoreTextUtilsProvider); this.timeUtils = injector.get(CoreTimeUtilsProvider); this.translate = injector.get(TranslateService); @@ -1357,55 +1354,62 @@ export class CoreSite { * @param retrying True if we're retrying the check. * @return Promise resolved when the check is done. */ - checkLocalMobilePlugin(retrying?: boolean): Promise { + async checkLocalMobilePlugin(retrying?: boolean): Promise { const checkUrl = this.siteUrl + '/local/mobile/check.php', service = CoreConfigConstants.wsextservice; if (!service) { // External service not defined. - return Promise.resolve({ code: 0 }); + return { code: 0 }; } - const promise = this.http.post(checkUrl, { service: service }).timeout(this.wsProvider.getRequestTimeout()).toPromise(); + let data; - return promise.then((data: any) => { - if (typeof data != 'undefined' && data.errorcode === 'requirecorrectaccess') { - if (!retrying) { - this.siteUrl = this.urlUtils.addOrRemoveWWW(this.siteUrl); + try { + const response = await this.wsProvider.sendHTTPRequest(checkUrl, { + method: 'post', + data: { service: service }, + }); - return this.checkLocalMobilePlugin(true); - } else { - return Promise.reject(data.error); - } - } else if (typeof data == 'undefined' || typeof data.code == 'undefined') { - // The local_mobile returned something we didn't expect. Let's assume it's not installed. - return { code: 0, warning: 'core.login.localmobileunexpectedresponse' }; - } - - const code = parseInt(data.code, 10); - if (data.error) { - switch (code) { - case 1: - // Site in maintenance mode. - return Promise.reject(this.translate.instant('core.login.siteinmaintenance')); - case 2: - // Web services not enabled. - return Promise.reject(this.translate.instant('core.login.webservicesnotenabled')); - case 3: - // Extended service not enabled, but the official is enabled. - return { code: 0 }; - case 4: - // Neither extended or official services enabled. - return Promise.reject(this.translate.instant('core.login.mobileservicesnotenabled')); - default: - return Promise.reject(this.translate.instant('core.unexpectederror')); - } - } else { - return { code: code, service: service, coreSupported: !!data.coresupported }; - } - }, () => { + data = response.body; + } catch (ex) { return { code: 0 }; - }); + } + + if (typeof data != 'undefined' && data.errorcode === 'requirecorrectaccess') { + if (!retrying) { + this.siteUrl = this.urlUtils.addOrRemoveWWW(this.siteUrl); + + return this.checkLocalMobilePlugin(true); + } else { + throw data.error; + } + } else if (typeof data == 'undefined' || typeof data.code == 'undefined') { + // The local_mobile returned something we didn't expect. Let's assume it's not installed. + return { code: 0, warning: 'core.login.localmobileunexpectedresponse' }; + } + + const code = parseInt(data.code, 10); + if (data.error) { + switch (code) { + case 1: + // Site in maintenance mode. + throw this.translate.instant('core.login.siteinmaintenance'); + case 2: + // Web services not enabled. + throw this.translate.instant('core.login.webservicesnotenabled'); + case 3: + // Extended service not enabled, but the official is enabled. + return { code: 0 }; + case 4: + // Neither extended or official services enabled. + throw this.translate.instant('core.login.mobileservicesnotenabled'); + default: + throw this.translate.instant('core.unexpectederror'); + } + } else { + return { code: code, service: service, coreSupported: !!data.coresupported }; + } } /** @@ -1970,7 +1974,7 @@ export class CoreSite { url = this.fixPluginfileURL(url); this.tokenPluginFileWorksPromise = this.wsProvider.performHead(url).then((result) => { - return result.ok; + return result.status >= 200 && result.status < 300; }).catch((error) => { // Error performing head request. return false; diff --git a/src/providers/ws.ts b/src/providers/ws.ts index 9f8743a86..57c91985a 100644 --- a/src/providers/ws.ts +++ b/src/providers/ws.ts @@ -25,6 +25,8 @@ import { CoreConstants } from '@core/constants'; import { Md5 } from 'ts-md5/dist/md5'; import { CoreInterceptor } from '@classes/interceptor'; import { makeSingleton } from '@singletons/core.singletons'; +import { Observable } from 'rxjs/Observable'; +import { CoreNativeToAngularHttpResponse } from '@classes/native-to-angular-http'; /** * PreSets accepted by the WS call. @@ -81,6 +83,61 @@ export interface CoreWSAjaxPreSets { useGet?: boolean; } +/** + * Options for HTTP requests. + */ +export type HttpRequestOptions = { + /** + * The HTTP method. + */ + method: string; + + /** + * Payload to send to the server. Only applicable on post, put or patch methods. + */ + data?: any; + + /** + * Query params to be appended to the URL (only applicable on get, head, delete, upload or download methods). + */ + params?: any; + + /** + * Response type. Defaults to json. + */ + responseType?: 'json' | 'text' | 'arraybuffer' | 'blob'; + + /** + * Timeout for the request in seconds. If undefined, the default value will be used. If null, no timeout. + */ + timeout?: number | null; + + /** + * Serializer to use. Defaults to 'urlencoded'. Only for mobile environments. + */ + serializer?: string; + + /** + * Whether to follow redirects. Defaults to true. Only for mobile environments. + */ + followRedirect?: boolean; + + /** + * Headers. Only for mobile environments. + */ + headers?: {[name: string]: string}; + + /** + * File paths to use for upload or download. Only for mobile environments. + */ + filePath?: string; + + /** + * Name to use during upload. Only for mobile environments. + */ + name?: string; +}; + /** * This service allows performing WS calls and download/upload files. */ @@ -377,8 +434,8 @@ export class CoreWSProvider { return Promise.resolve(this.mimeTypeCache[url]); } - return this.performHead(url).then((data) => { - let mimeType = data.headers.get('Content-Type'); + return this.performHead(url).then((response) => { + let mimeType = response.headers.get('Content-Type'); if (mimeType) { // Remove "parameters" like charset. mimeType = mimeType.split(';')[0]; @@ -399,8 +456,8 @@ export class CoreWSProvider { * @return Promise resolved with the size or -1 if failure. */ getRemoteFileSize(url: string): Promise { - return this.performHead(url).then((data) => { - const size = parseInt(data.headers.get('Content-Length'), 10); + return this.performHead(url).then((response) => { + const size = parseInt(response.headers.get('Content-Length'), 10); if (size) { return size; @@ -463,12 +520,12 @@ export class CoreWSProvider { preSets.responseExpected = true; } - const script = preSets.noLogin ? 'service-nologin.php' : 'service.php', - ajaxData = JSON.stringify([{ - index: 0, - methodname: method, - args: this.convertValuesToString(data) - }]); + const script = preSets.noLogin ? 'service-nologin.php' : 'service.php'; + const ajaxData = [{ + index: 0, + methodname: method, + args: this.convertValuesToString(data) + }]; // The info= parameter has no function. It is just to help with debugging. // We call it info to match the parameter name use by Moodle's AMD ajax module. @@ -476,13 +533,22 @@ export class CoreWSProvider { if (preSets.noLogin && preSets.useGet) { // Send params using GET. - siteUrl += '&args=' + encodeURIComponent(ajaxData); - promise = this.http.get(siteUrl).timeout(this.getRequestTimeout()).toPromise(); + siteUrl += '&args=' + encodeURIComponent(JSON.stringify(ajaxData)); + + promise = this.sendHTTPRequest(siteUrl, { + method: 'get', + }); } else { - promise = this.http.post(siteUrl, ajaxData).timeout(this.getRequestTimeout()).toPromise(); + promise = this.sendHTTPRequest(siteUrl, { + method: 'post', + data: ajaxData, + serializer: 'json', + }); } - return promise.then((data: any) => { + return promise.then((response: HttpResponse) => { + let data = response.body; + // Some moodle web services return null. // If the responseExpected value is set then so long as no data is returned, we create a blank object. if (!data && !preSets.responseExpected) { @@ -536,8 +602,11 @@ export class CoreWSProvider { let promise = this.getPromiseHttp('head', url); if (!promise) { - promise = this.http.head(url, {observe: 'response', responseType: 'blob'}).timeout(this.getRequestTimeout()) - .toPromise(); + promise = this.sendHTTPRequest(url, { + method: 'head', + responseType: 'text', + }); + promise = this.setPromiseHttp(promise, 'head', url); } @@ -571,6 +640,7 @@ export class CoreWSProvider { const promise = this.http.post(requestUrl, ajaxData, options).timeout(this.getRequestTimeout()).toPromise(); return promise.then((data: any) => { + // Some moodle web services return null. // If the responseExpected value is set to false, we create a blank object if the response is null. if (!data && !preSets.responseExpected) { @@ -871,13 +941,103 @@ export class CoreWSProvider { */ async getText(url: string): Promise { // Fetch the URL content. - const content = await this.http.get(url, { responseType: 'text' }).toPromise(); + const options: HttpRequestOptions = { + method: 'get', + responseType: 'text', + }; + + const response = await this.sendHTTPRequest(url, options); + + const content = response.body; + if (typeof content !== 'string') { - return Promise.reject(null); + throw 'Error reading content'; } return content; } + + /** + * Send an HTTP request. In mobile devices it will use the cordova plugin. + * + * @param url URL of the request. + * @param options Options for the request. + * @return Promise resolved with the response. + */ + async sendHTTPRequest(url: string, options: HttpRequestOptions): Promise> { + + // Set default values. + options.responseType = options.responseType || 'json'; + options.timeout = typeof options.timeout == 'undefined' ? this.getRequestTimeout() : options.timeout; + + if (this.appProvider.isMobile()) { + // Use the cordova plugin. + if (url.indexOf('file://') === 0) { + // We cannot load local files using the http native plugin. Use file provider instead. + const format = options.responseType == 'json' ? CoreFileProvider.FORMATJSON : CoreFileProvider.FORMATTEXT; + + const content = await this.fileProvider.readFile(url, format); + + return new HttpResponse({ + body: content, + headers: null, + status: 200, + statusText: 'OK', + url: url + }); + } + + return new Promise>((resolve, reject): void => { + // We cannot use Ionic Native plugin because it doesn't have the sendRequest method. + ( cordova).plugin.http.sendRequest(url, options, (response) => { + resolve(new CoreNativeToAngularHttpResponse(response)); + }, reject); + }); + } else { + let observable: Observable; + + // Use Angular's library. + switch (options.method) { + case 'get': + observable = this.http.get(url, { + headers: options.headers, + params: options.params, + observe: 'response', + responseType: options.responseType, + }); + break; + + case 'post': + if (options.serializer == 'json') { + options.data = JSON.stringify(options.data); + } + + observable = this.http.post(url, options.data, { + headers: options.headers, + observe: 'response', + responseType: options.responseType, + }); + break; + + case 'head': + observable = this.http.head(url, { + headers: options.headers, + observe: 'response', + responseType: options.responseType + }); + break; + + default: + return Promise.reject('Method not implemented yet.'); + } + + if (options.timeout) { + observable = observable.timeout(options.timeout); + } + + return observable.toPromise(); + } + } } export class CoreWS extends makeSingleton(CoreWSProvider) {}