MOBILE-3565 core: Migrate most core providers
parent
811bb39781
commit
e63a59eec1
|
@ -21,8 +21,32 @@ import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
|
|
||||||
|
// Import core services.
|
||||||
import { CoreAppProvider } from '@services/app';
|
import { CoreAppProvider } from '@services/app';
|
||||||
|
import { CoreConfigProvider } from '@services/config';
|
||||||
|
import { CoreCronDelegate } from '@services/cron';
|
||||||
|
import { CoreDbProvider } from '@services/db';
|
||||||
|
import { CoreEventsProvider } from '@services/events';
|
||||||
|
import { CoreFileHelperProvider } from '@services/file-helper';
|
||||||
|
import { CoreFileSessionProvider } from '@services/file-session';
|
||||||
|
import { CoreFileProvider } from '@services/file';
|
||||||
|
import { CoreFilepoolProvider } from '@services/filepool';
|
||||||
|
import { CoreGeolocationProvider } from '@services/geolocation';
|
||||||
|
import { CoreGroupsProvider } from '@services/groups';
|
||||||
import { CoreInitDelegate } from '@services/init';
|
import { CoreInitDelegate } from '@services/init';
|
||||||
|
import { CoreLangProvider } from '@services/lang';
|
||||||
|
import { CoreLocalNotificationsProvider } from '@services/local-notifications';
|
||||||
|
import { CorePluginFileDelegate } from '@services/plugin-file-delegate';
|
||||||
|
import { CoreSitesProvider } from '@services/sites';
|
||||||
|
import { CoreSyncProvider } from '@services/sync';
|
||||||
|
import { CoreUpdateManager } from '@services/update-manager';
|
||||||
|
import { CoreWSProvider } from '@services/ws';
|
||||||
|
import { CoreDomUtilsProvider } from '@services/utils/dom';
|
||||||
|
import { CoreIframeUtilsProvider } from '@services/utils/iframe';
|
||||||
|
import { CoreMimetypeUtilsProvider } from '@services/utils/mimetype';
|
||||||
|
import { CoreTextUtilsProvider } from '@services/utils/text';
|
||||||
|
import { CoreTimeUtilsProvider } from '@services/utils/time';
|
||||||
|
import { CoreUrlUtilsProvider } from '@services/utils/url';
|
||||||
import { CoreUtilsProvider } from '@services/utils/utils';
|
import { CoreUtilsProvider } from '@services/utils/utils';
|
||||||
|
|
||||||
import { CoreEmulatorModule } from '@core/emulator/emulator.module';
|
import { CoreEmulatorModule } from '@core/emulator/emulator.module';
|
||||||
|
@ -43,7 +67,30 @@ import { setSingletonsInjector } from '@singletons/core.singletons';
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||||
CoreAppProvider,
|
CoreAppProvider,
|
||||||
|
CoreConfigProvider,
|
||||||
|
CoreCronDelegate,
|
||||||
|
CoreDbProvider,
|
||||||
|
CoreEventsProvider,
|
||||||
|
CoreFileHelperProvider,
|
||||||
|
CoreFileSessionProvider,
|
||||||
|
CoreFileProvider,
|
||||||
|
CoreFilepoolProvider,
|
||||||
|
CoreGeolocationProvider,
|
||||||
|
CoreGroupsProvider,
|
||||||
CoreInitDelegate,
|
CoreInitDelegate,
|
||||||
|
CoreLangProvider,
|
||||||
|
CoreLocalNotificationsProvider,
|
||||||
|
CorePluginFileDelegate,
|
||||||
|
CoreSitesProvider,
|
||||||
|
CoreSyncProvider,
|
||||||
|
CoreUpdateManager,
|
||||||
|
CoreWSProvider,
|
||||||
|
CoreDomUtilsProvider,
|
||||||
|
CoreIframeUtilsProvider,
|
||||||
|
CoreMimetypeUtilsProvider,
|
||||||
|
CoreTextUtilsProvider,
|
||||||
|
CoreTimeUtilsProvider,
|
||||||
|
CoreUrlUtilsProvider,
|
||||||
CoreUtilsProvider,
|
CoreUtilsProvider,
|
||||||
],
|
],
|
||||||
bootstrap: [AppComponent],
|
bootstrap: [AppComponent],
|
||||||
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
{
|
||||||
|
"app_id": "com.moodle.moodlemobile",
|
||||||
|
"appname": "Moodle Mobile",
|
||||||
|
"desktopappname": "Moodle Desktop",
|
||||||
|
"versioncode": 3930,
|
||||||
|
"versionname": "3.9.3-dev",
|
||||||
|
"cache_update_frequency_usually": 420000,
|
||||||
|
"cache_update_frequency_often": 1200000,
|
||||||
|
"cache_update_frequency_sometimes": 3600000,
|
||||||
|
"cache_update_frequency_rarely": 43200000,
|
||||||
|
"default_lang": "en",
|
||||||
|
"languages": {
|
||||||
|
"ar": "عربي",
|
||||||
|
"bg": "Български",
|
||||||
|
"ca": "Català",
|
||||||
|
"cs": "Čeština",
|
||||||
|
"da": "Dansk",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"de-du": "Deutsch - Du",
|
||||||
|
"el": "Ελληνικά",
|
||||||
|
"en": "English",
|
||||||
|
"en-us": "English - United States",
|
||||||
|
"es": "Español",
|
||||||
|
"es-mx": "Español - México",
|
||||||
|
"eu": "Euskara",
|
||||||
|
"fa": "فارسی",
|
||||||
|
"fi": "Suomi",
|
||||||
|
"fr": "Français",
|
||||||
|
"he": "עברית",
|
||||||
|
"hi": "हिंदी",
|
||||||
|
"hr": "Hrvatski",
|
||||||
|
"hu": "magyar",
|
||||||
|
"id": "Indonesian",
|
||||||
|
"it": "Italiano",
|
||||||
|
"ja": "日本語",
|
||||||
|
"km": "ខ្មែរ",
|
||||||
|
"kn": "ಕನ್ನಡ",
|
||||||
|
"ko": "한국어",
|
||||||
|
"lt": "Lietuvių",
|
||||||
|
"lv": "Latviešu",
|
||||||
|
"mn": "Монгол",
|
||||||
|
"mr": "मराठी",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"no": "Norsk - bokmål",
|
||||||
|
"pl": "Polski",
|
||||||
|
"pt": "Português - Portugal",
|
||||||
|
"pt-br": "Português - Brasil",
|
||||||
|
"ro": "Română",
|
||||||
|
"ru": "Русский",
|
||||||
|
"sl": "Slovenščina",
|
||||||
|
"sr-cr": "Српски",
|
||||||
|
"sr-lt": "Srpski",
|
||||||
|
"sv": "Svenska",
|
||||||
|
"tg": "Тоҷикӣ",
|
||||||
|
"tr": "Türkçe",
|
||||||
|
"uk": "Українська",
|
||||||
|
"vi": "Vietnamese",
|
||||||
|
"zh-cn": "简体中文",
|
||||||
|
"zh-tw": "正體中文"
|
||||||
|
},
|
||||||
|
"wsservice": "moodle_mobile_app",
|
||||||
|
"wsextservice": "local_mobile",
|
||||||
|
"demo_sites": {
|
||||||
|
"student": {
|
||||||
|
"url": "https:\/\/school.moodledemo.net",
|
||||||
|
"username": "student",
|
||||||
|
"password": "moodle"
|
||||||
|
},
|
||||||
|
"teacher": {
|
||||||
|
"url": "https:\/\/school.moodledemo.net",
|
||||||
|
"username": "teacher",
|
||||||
|
"password": "moodle"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"font_sizes": [
|
||||||
|
62.5,
|
||||||
|
75.89,
|
||||||
|
93.75
|
||||||
|
],
|
||||||
|
"customurlscheme": "moodlemobile",
|
||||||
|
"siteurl": "",
|
||||||
|
"sitename": "",
|
||||||
|
"multisitesdisplay": "",
|
||||||
|
"sitefindersettings": {},
|
||||||
|
"onlyallowlistedsites": false,
|
||||||
|
"skipssoconfirmation": false,
|
||||||
|
"forcedefaultlanguage": false,
|
||||||
|
"privacypolicy": "https:\/\/moodle.net\/moodle-app-privacy\/",
|
||||||
|
"notificoncolor": "#f98012",
|
||||||
|
"statusbarbg": false,
|
||||||
|
"statusbarlighttext": false,
|
||||||
|
"statusbarbgios": "#f98012",
|
||||||
|
"statusbarlighttextios": true,
|
||||||
|
"statusbarbgandroid": "#df7310",
|
||||||
|
"statusbarlighttextandroid": true,
|
||||||
|
"statusbarbgremotetheme": "#000000",
|
||||||
|
"statusbarlighttextremotetheme": true,
|
||||||
|
"enableanalytics": false,
|
||||||
|
"enableonboarding": true,
|
||||||
|
"forceColorScheme": "",
|
||||||
|
"forceLoginLogo": false,
|
||||||
|
"ioswebviewscheme": "moodleappfs",
|
||||||
|
"appstores": {
|
||||||
|
"android": "com.moodle.moodlemobile",
|
||||||
|
"ios": "id633359593",
|
||||||
|
"windows": "moodle-desktop\/9p9bwvhdc8c8",
|
||||||
|
"mac": "id1255924440",
|
||||||
|
"linux": "https:\/\/download.moodle.org\/desktop\/download.php?platform=linux&arch=64"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,166 @@
|
||||||
|
// (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.
|
||||||
|
|
||||||
|
/* tslint:disable:no-console */
|
||||||
|
|
||||||
|
import { SQLiteDB } from '@classes/sqlitedb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to mock the interaction with the SQLite database.
|
||||||
|
*/
|
||||||
|
export class SQLiteDBMock extends SQLiteDB {
|
||||||
|
promise: Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and open the database.
|
||||||
|
*
|
||||||
|
* @param name Database name.
|
||||||
|
*/
|
||||||
|
constructor(public name: string) {
|
||||||
|
super(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the database.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
close(): Promise<any> {
|
||||||
|
// WebSQL databases aren't closed.
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop all the data in the database.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
emptyDatabase(): Promise<any> {
|
||||||
|
return new Promise((resolve, reject): void => {
|
||||||
|
this.db.transaction((tx) => {
|
||||||
|
// Query all tables from sqlite_master that we have created and can modify.
|
||||||
|
const args = [];
|
||||||
|
const query = `SELECT * FROM sqlite_master
|
||||||
|
WHERE name NOT LIKE 'sqlite\\_%' escape '\\' AND name NOT LIKE '\\_%' escape '\\'`;
|
||||||
|
|
||||||
|
tx.executeSql(query, args, (tx, result) => {
|
||||||
|
if (result.rows.length <= 0) {
|
||||||
|
// No tables to delete, stop.
|
||||||
|
resolve();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop all the tables.
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < result.rows.length; i++) {
|
||||||
|
promises.push(new Promise((resolve, reject): void => {
|
||||||
|
// Drop the table.
|
||||||
|
const name = JSON.stringify(result.rows.item(i).name);
|
||||||
|
tx.executeSql('DROP TABLE ' + name, [], resolve, reject);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(promises).then(resolve, reject);
|
||||||
|
}, reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a SQL query.
|
||||||
|
* IMPORTANT: Use this function only if you cannot use any of the other functions in this API. Please take into account that
|
||||||
|
* these query will be run in SQLite (Mobile) and Web SQL (desktop), so your query should work in both environments.
|
||||||
|
*
|
||||||
|
* @param sql SQL query to execute.
|
||||||
|
* @param params Query parameters.
|
||||||
|
* @return Promise resolved with the result.
|
||||||
|
*/
|
||||||
|
execute(sql: string, params?: any[]): Promise<any> {
|
||||||
|
return new Promise((resolve, reject): void => {
|
||||||
|
// With WebSQL, all queries must be run in a transaction.
|
||||||
|
this.db.transaction((tx) => {
|
||||||
|
tx.executeSql(sql, params, (tx, results) => {
|
||||||
|
resolve(results);
|
||||||
|
}, (tx, error) => {
|
||||||
|
console.error(sql, params, error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a set of SQL queries. This operation is atomic.
|
||||||
|
* IMPORTANT: Use this function only if you cannot use any of the other functions in this API. Please take into account that
|
||||||
|
* these query will be run in SQLite (Mobile) and Web SQL (desktop), so your query should work in both environments.
|
||||||
|
*
|
||||||
|
* @param sqlStatements SQL statements to execute.
|
||||||
|
* @return Promise resolved with the result.
|
||||||
|
*/
|
||||||
|
executeBatch(sqlStatements: any[]): Promise<any> {
|
||||||
|
return new Promise((resolve, reject): void => {
|
||||||
|
// Create a transaction to execute the queries.
|
||||||
|
this.db.transaction((tx) => {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
// Execute all the queries. Each statement can be a string or an array.
|
||||||
|
sqlStatements.forEach((statement) => {
|
||||||
|
promises.push(new Promise((resolve, reject): void => {
|
||||||
|
let query;
|
||||||
|
let params;
|
||||||
|
|
||||||
|
if (Array.isArray(statement)) {
|
||||||
|
query = statement[0];
|
||||||
|
params = statement[1];
|
||||||
|
} else {
|
||||||
|
query = statement;
|
||||||
|
params = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.executeSql(query, params, (tx, results) => {
|
||||||
|
resolve(results);
|
||||||
|
}, (tx, error) => {
|
||||||
|
console.error(query, params, error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promises).then(resolve, reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the database.
|
||||||
|
*/
|
||||||
|
init(): void {
|
||||||
|
// This DB is for desktop apps, so use a big size to be sure it isn't filled.
|
||||||
|
this.db = (<any> window).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024);
|
||||||
|
this.promise = Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the database. Only needed if it was closed before, a database is automatically opened when created.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
open(): Promise<void> {
|
||||||
|
// WebSQL databases can't closed, so the open method isn't needed.
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -12,36 +12,17 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable, NgZone, ApplicationRef } from '@angular/core';
|
||||||
|
import { Connection } from '@ionic-native/network/ngx';
|
||||||
|
|
||||||
import { makeSingleton } from '@singletons/core.singletons';
|
import { CoreDB } from '@services/db';
|
||||||
|
import { CoreEvents, CoreEventsProvider } from '@services/events';
|
||||||
|
import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb';
|
||||||
|
import CoreConfigConstants from '@app/config.json';
|
||||||
|
|
||||||
|
import { makeSingleton, Keyboard, Network, StatusBar, Platform } from '@singletons/core.singletons';
|
||||||
import { CoreLogger } from '@singletons/logger';
|
import { CoreLogger } from '@singletons/logger';
|
||||||
|
|
||||||
/**
|
|
||||||
* Data stored for a redirect to another page/site.
|
|
||||||
*/
|
|
||||||
export type CoreRedirectData = {
|
|
||||||
/**
|
|
||||||
* ID of the site to load.
|
|
||||||
*/
|
|
||||||
siteId?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the page to redirect to.
|
|
||||||
*/
|
|
||||||
page?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Params to pass to the page.
|
|
||||||
*/
|
|
||||||
params?: any;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Timestamp when this redirect was last modified.
|
|
||||||
*/
|
|
||||||
timemodified?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory to provide some global functionalities, like access to the global app database.
|
* Factory to provide some global functionalities, like access to the global app database.
|
||||||
* @description
|
* @description
|
||||||
|
@ -54,10 +35,531 @@ export type CoreRedirectData = {
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CoreAppProvider {
|
export class CoreAppProvider {
|
||||||
protected logger: CoreLogger;
|
protected DBNAME = 'MoodleMobile';
|
||||||
|
protected db: SQLiteDB;
|
||||||
|
protected logger;
|
||||||
|
protected ssoAuthenticationPromise: Promise<any>;
|
||||||
|
protected isKeyboardShown = false;
|
||||||
|
protected _isKeyboardOpening = false;
|
||||||
|
protected _isKeyboardClosing = false;
|
||||||
|
protected backActions = [];
|
||||||
|
protected mainMenuId = 0;
|
||||||
|
protected mainMenuOpen: number;
|
||||||
|
protected forceOffline = false;
|
||||||
|
|
||||||
|
// Variables for DB.
|
||||||
|
protected createVersionsTableReady: Promise<any>;
|
||||||
|
protected SCHEMA_VERSIONS_TABLE = 'schema_versions';
|
||||||
|
protected versionsTableSchema: SQLiteDBTableSchema = {
|
||||||
|
name: this.SCHEMA_VERSIONS_TABLE,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'TEXT',
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'version',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(appRef: ApplicationRef,
|
||||||
|
zone: NgZone) {
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.logger = CoreLogger.getInstance('CoreAppProvider');
|
this.logger = CoreLogger.getInstance('CoreAppProvider');
|
||||||
|
this.db = CoreDB.instance.getDB(this.DBNAME);
|
||||||
|
|
||||||
|
// Create the schema versions table.
|
||||||
|
this.createVersionsTableReady = this.db.createTableFromSchema(this.versionsTableSchema);
|
||||||
|
|
||||||
|
Keyboard.instance.onKeyboardShow().subscribe((data) => {
|
||||||
|
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||||
|
zone.run(() => {
|
||||||
|
document.body.classList.add('keyboard-is-open');
|
||||||
|
this.setKeyboardShown(true);
|
||||||
|
// Error on iOS calculating size.
|
||||||
|
// More info: https://github.com/ionic-team/ionic-plugin-keyboard/issues/276 .
|
||||||
|
CoreEvents.instance.trigger(CoreEventsProvider.KEYBOARD_CHANGE, data.keyboardHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Keyboard.instance.onKeyboardHide().subscribe((data) => {
|
||||||
|
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||||
|
zone.run(() => {
|
||||||
|
document.body.classList.remove('keyboard-is-open');
|
||||||
|
this.setKeyboardShown(false);
|
||||||
|
CoreEvents.instance.trigger(CoreEventsProvider.KEYBOARD_CHANGE, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Keyboard.instance.onKeyboardWillShow().subscribe((data) => {
|
||||||
|
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||||
|
zone.run(() => {
|
||||||
|
this._isKeyboardOpening = true;
|
||||||
|
this._isKeyboardClosing = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Keyboard.instance.onKeyboardWillHide().subscribe((data) => {
|
||||||
|
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||||
|
zone.run(() => {
|
||||||
|
this._isKeyboardOpening = false;
|
||||||
|
this._isKeyboardClosing = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// this.platform.registerBackButtonAction(() => {
|
||||||
|
// this.backButtonAction();
|
||||||
|
// }, 100);
|
||||||
|
|
||||||
|
// Export the app provider and appRef to control the application in Behat tests.
|
||||||
|
if (CoreAppProvider.isAutomated()) {
|
||||||
|
(<any> window).appProvider = this;
|
||||||
|
(<any> window).appRef = appRef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the user agent is controlled by automation. I.e. Behat testing.
|
||||||
|
*
|
||||||
|
* @return True if the user agent is controlled by automation, false otherwise.
|
||||||
|
*/
|
||||||
|
static isAutomated(): boolean {
|
||||||
|
return !!navigator.webdriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the browser supports mediaDevices.getUserMedia.
|
||||||
|
*
|
||||||
|
* @return Whether the function is supported.
|
||||||
|
*/
|
||||||
|
canGetUserMedia(): boolean {
|
||||||
|
return !!(navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the browser supports MediaRecorder.
|
||||||
|
*
|
||||||
|
* @return Whether the function is supported.
|
||||||
|
*/
|
||||||
|
canRecordMedia(): boolean {
|
||||||
|
return !!(<any> window).MediaRecorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the keyboard.
|
||||||
|
*/
|
||||||
|
closeKeyboard(): void {
|
||||||
|
if (this.isMobile()) {
|
||||||
|
Keyboard.instance.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install and upgrade a certain schema.
|
||||||
|
*
|
||||||
|
* @param schema The schema to create.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async createTablesFromSchema(schema: CoreAppSchema): Promise<any> {
|
||||||
|
this.logger.debug(`Apply schema to app DB: ${schema.name}`);
|
||||||
|
|
||||||
|
let oldVersion;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for the schema versions table to be created.
|
||||||
|
await this.createVersionsTableReady;
|
||||||
|
|
||||||
|
// Fetch installed version of the schema.
|
||||||
|
const entry = await this.db.getRecord(this.SCHEMA_VERSIONS_TABLE, {name: schema.name});
|
||||||
|
oldVersion = entry.version;
|
||||||
|
} catch (error) {
|
||||||
|
// No installed version yet.
|
||||||
|
oldVersion = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion >= schema.version) {
|
||||||
|
// Version already installed, nothing else to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Migrating schema '${schema.name}' of app DB from version ${oldVersion} to ${schema.version}`);
|
||||||
|
|
||||||
|
if (schema.tables) {
|
||||||
|
await this.db.createTablesFromSchema(schema.tables);
|
||||||
|
}
|
||||||
|
if (schema.migrate) {
|
||||||
|
await schema.migrate(this.db, oldVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set installed version.
|
||||||
|
await this.db.insertRecord(this.SCHEMA_VERSIONS_TABLE, {name: schema.name, version: schema.version});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the application global database.
|
||||||
|
*
|
||||||
|
* @return App's DB.
|
||||||
|
*/
|
||||||
|
getDB(): SQLiteDB {
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an ID for a main menu.
|
||||||
|
*
|
||||||
|
* @return Main menu ID.
|
||||||
|
*/
|
||||||
|
getMainMenuId(): number {
|
||||||
|
return this.mainMenuId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app store URL.
|
||||||
|
*
|
||||||
|
* @param storesConfig Config params to send the user to the right place.
|
||||||
|
* @return Store URL.
|
||||||
|
*/
|
||||||
|
getAppStoreUrl(storesConfig: CoreStoreConfig): string {
|
||||||
|
if (this.isMac() && storesConfig.mac) {
|
||||||
|
return 'itms-apps://itunes.apple.com/app/' + storesConfig.mac;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isWindows() && storesConfig.windows) {
|
||||||
|
return 'https://www.microsoft.com/p/' + storesConfig.windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isLinux() && storesConfig.linux) {
|
||||||
|
return storesConfig.linux;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDesktop() && storesConfig.desktop) {
|
||||||
|
return storesConfig.desktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isIOS() && storesConfig.ios) {
|
||||||
|
return 'itms-apps://itunes.apple.com/app/' + storesConfig.ios;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isAndroid() && storesConfig.android) {
|
||||||
|
return 'market://details?id=' + storesConfig.android;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMobile() && storesConfig.mobile) {
|
||||||
|
return storesConfig.mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return storesConfig.default || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the app is running in a 64 bits desktop environment (not browser).
|
||||||
|
*
|
||||||
|
* @return Whether the app is running in a 64 bits desktop environment (not browser).
|
||||||
|
*/
|
||||||
|
is64Bits(): boolean {
|
||||||
|
const process = (<any> window).process;
|
||||||
|
|
||||||
|
return this.isDesktop() && process.arch == 'x64';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the app is running in an Android mobile or tablet device.
|
||||||
|
*
|
||||||
|
* @return Whether the app is running in an Android mobile or tablet device.
|
||||||
|
*/
|
||||||
|
isAndroid(): boolean {
|
||||||
|
return this.isMobile() && Platform.instance.is('android');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the app is running in a desktop environment (not browser).
|
||||||
|
*
|
||||||
|
* @return Whether the app is running in a desktop environment (not browser).
|
||||||
|
*/
|
||||||
|
isDesktop(): boolean {
|
||||||
|
const process = (<any> window).process;
|
||||||
|
|
||||||
|
return !!(process && process.versions && typeof process.versions.electron != 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the app is running in an iOS mobile or tablet device.
|
||||||
|
*
|
||||||
|
* @return Whether the app is running in an iOS mobile or tablet device.
|
||||||
|
*/
|
||||||
|
isIOS(): boolean {
|
||||||
|
return this.isMobile() && !Platform.instance.is('android');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the keyboard is closing.
|
||||||
|
*
|
||||||
|
* @return Whether keyboard is closing (animating).
|
||||||
|
*/
|
||||||
|
isKeyboardClosing(): boolean {
|
||||||
|
return this._isKeyboardClosing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the keyboard is being opened.
|
||||||
|
*
|
||||||
|
* @return Whether keyboard is opening (animating).
|
||||||
|
*/
|
||||||
|
isKeyboardOpening(): boolean {
|
||||||
|
return this._isKeyboardOpening;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the keyboard is visible.
|
||||||
|
*
|
||||||
|
* @return Whether keyboard is visible.
|
||||||
|
*/
|
||||||
|
isKeyboardVisible(): boolean {
|
||||||
|
return this.isKeyboardShown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app is running in a Linux environment.
|
||||||
|
*
|
||||||
|
* @return Whether it's running in a Linux environment.
|
||||||
|
*/
|
||||||
|
isLinux(): boolean {
|
||||||
|
if (!this.isDesktop()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @todo return require('os').platform().indexOf('linux') === 0;
|
||||||
|
} catch (ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app is running in a Mac OS environment.
|
||||||
|
*
|
||||||
|
* @return Whether it's running in a Mac OS environment.
|
||||||
|
*/
|
||||||
|
isMac(): boolean {
|
||||||
|
if (!this.isDesktop()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @todo return require('os').platform().indexOf('darwin') === 0;
|
||||||
|
} catch (ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the main menu is open.
|
||||||
|
*
|
||||||
|
* @return Whether the main menu is open.
|
||||||
|
*/
|
||||||
|
isMainMenuOpen(): boolean {
|
||||||
|
return typeof this.mainMenuOpen != 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the app is running in a mobile or tablet device (Cordova).
|
||||||
|
*
|
||||||
|
* @return Whether the app is running in a mobile or tablet device.
|
||||||
|
*/
|
||||||
|
isMobile(): boolean {
|
||||||
|
return Platform.instance.is('cordova');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current window is wider than a mobile.
|
||||||
|
*
|
||||||
|
* @return Whether the app the current window is wider than a mobile.
|
||||||
|
*/
|
||||||
|
isWide(): boolean {
|
||||||
|
return Platform.instance.width() > 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether we are online.
|
||||||
|
*
|
||||||
|
* @return Whether the app is online.
|
||||||
|
*/
|
||||||
|
isOnline(): boolean {
|
||||||
|
if (this.forceOffline) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let online = Network.instance.type !== null && Number(Network.instance.type) != Connection.NONE &&
|
||||||
|
Number(Network.instance.type) != Connection.UNKNOWN;
|
||||||
|
// Double check we are not online because we cannot rely 100% in Cordova APIs. Also, check it in browser.
|
||||||
|
if (!online && navigator.onLine) {
|
||||||
|
online = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return online;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device uses a limited connection.
|
||||||
|
*
|
||||||
|
* @return Whether the device uses a limited connection.
|
||||||
|
*/
|
||||||
|
isNetworkAccessLimited(): boolean {
|
||||||
|
const type = Network.instance.type;
|
||||||
|
if (type === null) {
|
||||||
|
// Plugin not defined, probably in browser.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limited = [Connection.CELL_2G, Connection.CELL_3G, Connection.CELL_4G, Connection.CELL];
|
||||||
|
|
||||||
|
return limited.indexOf(Number(type)) > -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device uses a wifi connection.
|
||||||
|
*
|
||||||
|
* @return Whether the device uses a wifi connection.
|
||||||
|
*/
|
||||||
|
isWifi(): boolean {
|
||||||
|
return this.isOnline() && !this.isNetworkAccessLimited();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app is running in a Windows environment.
|
||||||
|
*
|
||||||
|
* @return Whether it's running in a Windows environment.
|
||||||
|
*/
|
||||||
|
isWindows(): boolean {
|
||||||
|
if (!this.isDesktop()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @todo return require('os').platform().indexOf('win') === 0;
|
||||||
|
} catch (ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the keyboard.
|
||||||
|
*/
|
||||||
|
openKeyboard(): void {
|
||||||
|
// Open keyboard is not supported in desktop and in iOS.
|
||||||
|
if (this.isAndroid()) {
|
||||||
|
Keyboard.instance.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set keyboard shown or hidden.
|
||||||
|
*
|
||||||
|
* @param Whether the keyboard is shown or hidden.
|
||||||
|
*/
|
||||||
|
protected setKeyboardShown(shown: boolean): void {
|
||||||
|
this.isKeyboardShown = shown;
|
||||||
|
this._isKeyboardOpening = false;
|
||||||
|
this._isKeyboardClosing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a main menu as open or not.
|
||||||
|
*
|
||||||
|
* @param id Main menu ID.
|
||||||
|
* @param open Whether it's open or not.
|
||||||
|
*/
|
||||||
|
setMainMenuOpen(id: number, open: boolean): void {
|
||||||
|
if (open) {
|
||||||
|
this.mainMenuOpen = id;
|
||||||
|
CoreEvents.instance.trigger(CoreEventsProvider.MAIN_MENU_OPEN);
|
||||||
|
} else if (this.mainMenuOpen == id) {
|
||||||
|
delete this.mainMenuOpen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an SSO authentication process.
|
||||||
|
* Please notice that this function should be called when the app receives the new token from the browser,
|
||||||
|
* NOT when the browser is opened.
|
||||||
|
*/
|
||||||
|
startSSOAuthentication(): void {
|
||||||
|
let cancelTimeout;
|
||||||
|
let resolvePromise;
|
||||||
|
|
||||||
|
this.ssoAuthenticationPromise = new Promise((resolve, reject): void => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
|
||||||
|
// Resolve it automatically after 10 seconds (it should never take that long).
|
||||||
|
cancelTimeout = setTimeout(() => {
|
||||||
|
this.finishSSOAuthentication();
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the resolve function in the promise itself.
|
||||||
|
(<any> this.ssoAuthenticationPromise).resolve = resolvePromise;
|
||||||
|
|
||||||
|
// If the promise is resolved because finishSSOAuthentication is called, stop the cancel promise.
|
||||||
|
this.ssoAuthenticationPromise.then(() => {
|
||||||
|
clearTimeout(cancelTimeout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish an SSO authentication process.
|
||||||
|
*/
|
||||||
|
finishSSOAuthentication(): void {
|
||||||
|
if (this.ssoAuthenticationPromise) {
|
||||||
|
(<any> this.ssoAuthenticationPromise).resolve && (<any> this.ssoAuthenticationPromise).resolve();
|
||||||
|
this.ssoAuthenticationPromise = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there's an ongoing SSO authentication process.
|
||||||
|
*
|
||||||
|
* @return Whether there's a SSO authentication ongoing.
|
||||||
|
*/
|
||||||
|
isSSOAuthenticationOngoing(): boolean {
|
||||||
|
return !!this.ssoAuthenticationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a promise that will be resolved once SSO authentication finishes.
|
||||||
|
*
|
||||||
|
* @return Promise resolved once SSO authentication finishes.
|
||||||
|
*/
|
||||||
|
waitForSSOAuthentication(): Promise<any> {
|
||||||
|
return this.ssoAuthenticationPromise || Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the application is resumed.
|
||||||
|
*
|
||||||
|
* @param timeout Maximum time to wait, use null to wait forever.
|
||||||
|
*/
|
||||||
|
async waitForResume(timeout: number | null = null): Promise<void> {
|
||||||
|
let resolve: (value?: any) => void;
|
||||||
|
let resumeSubscription: any;
|
||||||
|
let timeoutId: NodeJS.Timer | false;
|
||||||
|
|
||||||
|
const promise = new Promise((r): any => resolve = r);
|
||||||
|
const stopWaiting = (): any => {
|
||||||
|
if (!resolve) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
resumeSubscription.unsubscribe();
|
||||||
|
timeoutId && clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
resolve = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
resumeSubscription = Platform.instance.resume.subscribe(stopWaiting);
|
||||||
|
timeoutId = timeout ? setTimeout(stopWaiting, timeout) : false;
|
||||||
|
|
||||||
|
await promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -107,6 +609,195 @@ export class CoreAppProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The back button event is triggered when the user presses the native
|
||||||
|
* platform's back button, also referred to as the "hardware" back button.
|
||||||
|
* This event is only used within Cordova apps running on Android and
|
||||||
|
* Windows platforms. This event is not fired on iOS since iOS doesn't come
|
||||||
|
* with a hardware back button in the same sense an Android or Windows device
|
||||||
|
* does.
|
||||||
|
*
|
||||||
|
* Registering a hardware back button action and setting a priority allows
|
||||||
|
* apps to control which action should be called when the hardware back
|
||||||
|
* button is pressed. This method decides which of the registered back button
|
||||||
|
* actions has the highest priority and should be called.
|
||||||
|
*
|
||||||
|
* @param fn Called when the back button is pressed,
|
||||||
|
* if this registered action has the highest priority.
|
||||||
|
* @param priority Set the priority for this action. All actions sorted by priority will be executed since one of
|
||||||
|
* them returns true.
|
||||||
|
* * Priorities higher or equal than 1000 will go before closing modals
|
||||||
|
* * Priorities lower than 500 will only be executed if you are in the first state of the app (before exit).
|
||||||
|
* @return A function that, when called, will unregister
|
||||||
|
* the back button action.
|
||||||
|
*/
|
||||||
|
registerBackButtonAction(fn: any, priority: number = 0): any {
|
||||||
|
const action = { fn, priority };
|
||||||
|
|
||||||
|
this.backActions.push(action);
|
||||||
|
|
||||||
|
this.backActions.sort((a, b) => {
|
||||||
|
return b.priority - a.priority;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (): boolean => {
|
||||||
|
const index = this.backActions.indexOf(action);
|
||||||
|
|
||||||
|
return index >= 0 && !!this.backActions.splice(index, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set StatusBar color depending on platform.
|
||||||
|
*/
|
||||||
|
setStatusBarColor(): void {
|
||||||
|
if (typeof CoreConfigConstants.statusbarbgios == 'string' && this.isIOS()) {
|
||||||
|
// IOS Status bar properties.
|
||||||
|
StatusBar.instance.overlaysWebView(false);
|
||||||
|
StatusBar.instance.backgroundColorByHexString(CoreConfigConstants.statusbarbgios);
|
||||||
|
CoreConfigConstants.statusbarlighttextios ? StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault();
|
||||||
|
} else if (typeof CoreConfigConstants.statusbarbgandroid == 'string' && this.isAndroid()) {
|
||||||
|
// Android Status bar properties.
|
||||||
|
StatusBar.instance.backgroundColorByHexString(CoreConfigConstants.statusbarbgandroid);
|
||||||
|
CoreConfigConstants.statusbarlighttextandroid ?
|
||||||
|
StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault();
|
||||||
|
} else if (typeof CoreConfigConstants.statusbarbg == 'string') {
|
||||||
|
// Generic Status bar properties.
|
||||||
|
this.isIOS() && StatusBar.instance.overlaysWebView(false);
|
||||||
|
StatusBar.instance.backgroundColorByHexString(CoreConfigConstants.statusbarbg);
|
||||||
|
CoreConfigConstants.statusbarlighttext ? StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault();
|
||||||
|
} else {
|
||||||
|
// Default Status bar properties.
|
||||||
|
this.isAndroid() ? StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset StatusBar color if any was set.
|
||||||
|
*/
|
||||||
|
resetStatusBarColor(): void {
|
||||||
|
if (typeof CoreConfigConstants.statusbarbgremotetheme == 'string' &&
|
||||||
|
((typeof CoreConfigConstants.statusbarbgios == 'string' && this.isIOS()) ||
|
||||||
|
(typeof CoreConfigConstants.statusbarbgandroid == 'string' && this.isAndroid()) ||
|
||||||
|
typeof CoreConfigConstants.statusbarbg == 'string')) {
|
||||||
|
// If the status bar has been overriden and there's a fallback color for remote themes, use it now.
|
||||||
|
this.isIOS() && StatusBar.instance.overlaysWebView(false);
|
||||||
|
StatusBar.instance.backgroundColorByHexString(CoreConfigConstants.statusbarbgremotetheme);
|
||||||
|
CoreConfigConstants.statusbarlighttextremotetheme ?
|
||||||
|
StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set value of forceOffline flag. If true, the app will think the device is offline.
|
||||||
|
*
|
||||||
|
* @param value Value to set.
|
||||||
|
*/
|
||||||
|
setForceOffline(value: boolean): void {
|
||||||
|
this.forceOffline = !!value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CoreApp extends makeSingleton(CoreAppProvider) {}
|
export class CoreApp extends makeSingleton(CoreAppProvider) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data stored for a redirect to another page/site.
|
||||||
|
*/
|
||||||
|
export type CoreRedirectData = {
|
||||||
|
/**
|
||||||
|
* ID of the site to load.
|
||||||
|
*/
|
||||||
|
siteId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the page to redirect to.
|
||||||
|
*/
|
||||||
|
page?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Params to pass to the page.
|
||||||
|
*/
|
||||||
|
params?: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp when this redirect was last modified.
|
||||||
|
*/
|
||||||
|
timemodified?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store config data.
|
||||||
|
*/
|
||||||
|
export type CoreStoreConfig = {
|
||||||
|
/**
|
||||||
|
* ID of the Apple store where the desktop Mac app is uploaded.
|
||||||
|
*/
|
||||||
|
mac?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID of the Windows store where the desktop Windows app is uploaded.
|
||||||
|
*/
|
||||||
|
windows?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url with the desktop linux download link.
|
||||||
|
*/
|
||||||
|
linux?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback URL when the desktop options is not set.
|
||||||
|
*/
|
||||||
|
desktop?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID of the Apple store where the mobile iOS app is uploaded.
|
||||||
|
*/
|
||||||
|
ios?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID of the Google play store where the android app is uploaded.
|
||||||
|
*/
|
||||||
|
android?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback URL when the mobile options is not set.
|
||||||
|
*/
|
||||||
|
mobile?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback URL when the other fallbacks options are not set.
|
||||||
|
*/
|
||||||
|
default?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App DB schema and migration function.
|
||||||
|
*/
|
||||||
|
export type CoreAppSchema = {
|
||||||
|
/**
|
||||||
|
* Name of the schema.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest version of the schema (integer greater than 0).
|
||||||
|
*/
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tables to create when installing or upgrading the schema.
|
||||||
|
*/
|
||||||
|
tables?: SQLiteDBTableSchema[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates the schema to the latest version.
|
||||||
|
*
|
||||||
|
* Called when installing and upgrading the schema, after creating the defined tables.
|
||||||
|
*
|
||||||
|
* @param db The affected DB.
|
||||||
|
* @param oldVersion Old version of the schema or 0 if not installed.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
migrate?(db: SQLiteDB, oldVersion: number): Promise<any>;
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
// (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 { CoreApp, CoreAppSchema } from '@services/app';
|
||||||
|
import { SQLiteDB } from '@classes/sqlitedb';
|
||||||
|
import { makeSingleton } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory to provide access to dynamic and permanent config and settings.
|
||||||
|
* It should not be abused into a temporary storage.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreConfigProvider {
|
||||||
|
protected appDB: SQLiteDB;
|
||||||
|
protected TABLE_NAME = 'core_config';
|
||||||
|
protected tableSchema: CoreAppSchema = {
|
||||||
|
name: 'CoreConfigProvider',
|
||||||
|
version: 1,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: this.TABLE_NAME,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'TEXT',
|
||||||
|
unique: true,
|
||||||
|
notNull: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
protected dbReady: Promise<any>; // Promise resolved when the app DB is initialized.
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.appDB = CoreApp.instance.getDB();
|
||||||
|
this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an app setting.
|
||||||
|
*
|
||||||
|
* @param name The config name.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async delete(name: string): Promise<any> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
return this.appDB.deleteRecords(this.TABLE_NAME, { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an app setting.
|
||||||
|
*
|
||||||
|
* @param name The config name.
|
||||||
|
* @param defaultValue Default value to use if the entry is not found.
|
||||||
|
* @return Resolves upon success along with the config data. Reject on failure.
|
||||||
|
*/
|
||||||
|
async get(name: string, defaultValue?: any): Promise<any> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = await this.appDB.getRecord(this.TABLE_NAME, { name });
|
||||||
|
|
||||||
|
return entry.value;
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof defaultValue != 'undefined') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an app setting.
|
||||||
|
*
|
||||||
|
* @param name The config name.
|
||||||
|
* @param value The config value. Can only store number or strings.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async set(name: string, value: number | string): Promise<any> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
return this.appDB.insertRecord(this.TABLE_NAME, { name, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreConfig extends makeSingleton(CoreConfigProvider) {}
|
|
@ -0,0 +1,564 @@
|
||||||
|
// (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, NgZone } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreApp, CoreAppProvider, CoreAppSchema } from '@services/app';
|
||||||
|
import { CoreConfig } from '@services/config';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { CoreConstants } from '@core/constants';
|
||||||
|
import { SQLiteDB } from '@classes/sqlitedb';
|
||||||
|
|
||||||
|
import { makeSingleton, Network } from '@singletons/core.singletons';
|
||||||
|
import { CoreLogger } from '@singletons/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that all cron handlers must implement.
|
||||||
|
*/
|
||||||
|
export interface CoreCronHandler {
|
||||||
|
/**
|
||||||
|
* A name to identify the handler.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the handler is running. Used internally by the provider, there's no need to set it.
|
||||||
|
*/
|
||||||
|
running?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout ID for the handler scheduling. Used internally by the provider, there's no need to set it.
|
||||||
|
*/
|
||||||
|
timeout?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns handler's interval in milliseconds. Defaults to CoreCronDelegate.DEFAULT_INTERVAL.
|
||||||
|
*
|
||||||
|
* @return Interval time (in milliseconds).
|
||||||
|
*/
|
||||||
|
getInterval?(): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the process uses network or not. True if not defined.
|
||||||
|
*
|
||||||
|
* @return Whether the process uses network or not
|
||||||
|
*/
|
||||||
|
usesNetwork?(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether it's a synchronization process or not. True if not defined.
|
||||||
|
*
|
||||||
|
* @return Whether it's a synchronization process or not.
|
||||||
|
*/
|
||||||
|
isSync?(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the sync can be executed manually. Call isSync if not defined.
|
||||||
|
*
|
||||||
|
* @return Whether the sync can be executed manually.
|
||||||
|
*/
|
||||||
|
canManualSync?(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the process.
|
||||||
|
*
|
||||||
|
* @param siteId ID of the site affected. If not defined, all sites.
|
||||||
|
* @param force Determines if it's a forced execution.
|
||||||
|
* @return Promise resolved when done. If the promise is rejected, this function will be called again often,
|
||||||
|
* it shouldn't be abused.
|
||||||
|
*/
|
||||||
|
execute?(siteId?: string, force?: boolean): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Service to handle cron processes. The registered processes will be executed every certain time.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreCronDelegate {
|
||||||
|
// Constants.
|
||||||
|
static DEFAULT_INTERVAL = 3600000; // Default interval is 1 hour.
|
||||||
|
static MIN_INTERVAL = 300000; // Minimum interval is 5 minutes.
|
||||||
|
static DESKTOP_MIN_INTERVAL = 60000; // Minimum interval in desktop is 1 minute.
|
||||||
|
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: CoreAppSchema = {
|
||||||
|
name: 'CoreCronDelegate',
|
||||||
|
version: 1,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: this.CRON_TABLE,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'TEXT',
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
protected logger;
|
||||||
|
protected appDB: SQLiteDB;
|
||||||
|
protected dbReady: Promise<any>; // Promise resolved when the app DB is initialized.
|
||||||
|
protected handlers: { [s: string]: CoreCronHandler } = {};
|
||||||
|
protected queuePromise = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(zone: NgZone) {
|
||||||
|
this.logger = CoreLogger.getInstance('CoreCronDelegate');
|
||||||
|
|
||||||
|
this.appDB = CoreApp.instance.getDB();
|
||||||
|
this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the app is re-connected, start network handlers that were stopped.
|
||||||
|
Network.instance.onConnect().subscribe(() => {
|
||||||
|
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||||
|
zone.run(() => {
|
||||||
|
this.startNetworkHandlers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export the sync provider so Behat tests can trigger cron tasks without waiting.
|
||||||
|
if (CoreAppProvider.isAutomated()) {
|
||||||
|
(<any> window).cronProvider = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 name Name of the handler.
|
||||||
|
* @param force Wether the execution is forced (manual sync).
|
||||||
|
* @param siteId Site ID. If not defined, all sites.
|
||||||
|
* @return Promise resolved if handler is executed successfully, rejected otherwise.
|
||||||
|
*/
|
||||||
|
protected checkAndExecuteHandler(name: string, force?: boolean, siteId?: string): Promise<any> {
|
||||||
|
if (!this.handlers[name] || !this.handlers[name].execute) {
|
||||||
|
// Invalid handler.
|
||||||
|
this.logger.debug('Cannot execute handler because is invalid: ' + name);
|
||||||
|
|
||||||
|
return Promise.reject(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usesNetwork = this.handlerUsesNetwork(name);
|
||||||
|
const isSync = !force && this.isHandlerSync(name);
|
||||||
|
let promise;
|
||||||
|
|
||||||
|
if (usesNetwork && !CoreApp.instance.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 = CoreConfig.instance.get(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, false).then((syncOnlyOnWifi) => {
|
||||||
|
return !syncOnlyOnWifi || CoreApp.instance.isWifi();
|
||||||
|
});
|
||||||
|
} 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, force, siteId).then(() => {
|
||||||
|
this.logger.debug(`Execution of handler '${name}' was a success.`);
|
||||||
|
|
||||||
|
return this.setHandlerLastExecutionTime(name, Date.now()).then(() => {
|
||||||
|
this.scheduleNextExecution(name);
|
||||||
|
});
|
||||||
|
}, (error) => {
|
||||||
|
// Handler call failed. Retry soon.
|
||||||
|
this.logger.error(`Execution of handler '${name}' failed.`, error);
|
||||||
|
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 name Name of the handler.
|
||||||
|
* @param force Wether the execution is forced (manual sync).
|
||||||
|
* @param siteId Site ID. If not defined, all sites.
|
||||||
|
* @return Promise resolved when the handler finishes or reaches max time, rejected if it fails.
|
||||||
|
*/
|
||||||
|
protected executeHandler(name: string, force?: boolean, siteId?: string): Promise<any> {
|
||||||
|
return new Promise((resolve, reject): void => {
|
||||||
|
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, force)).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 siteId Site ID. If not defined, all sites.
|
||||||
|
* @return Promise resolved if all handlers are executed successfully, rejected otherwise.
|
||||||
|
*/
|
||||||
|
forceSyncExecution(siteId?: string): Promise<any> {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (const name in this.handlers) {
|
||||||
|
if (this.isHandlerManualSync(name)) {
|
||||||
|
// Now force the execution of the handler.
|
||||||
|
promises.push(this.forceCronHandlerExecution(name, siteId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoreUtils.instance.allPromises(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force execution of a 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 name If provided, the name of the handler.
|
||||||
|
* @param siteId Site ID. If not defined, all sites.
|
||||||
|
* @return Promise resolved if handler has been executed successfully, rejected otherwise.
|
||||||
|
*/
|
||||||
|
forceCronHandlerExecution(name?: string, siteId?: string): Promise<any> {
|
||||||
|
const handler = this.handlers[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.
|
||||||
|
return this.checkAndExecuteHandler(name, true, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a handler's interval.
|
||||||
|
*
|
||||||
|
* @param name Handler's name.
|
||||||
|
* @return Handler's interval.
|
||||||
|
*/
|
||||||
|
protected getHandlerInterval(name: string): 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 = CoreApp.instance.isDesktop() ? CoreCronDelegate.DESKTOP_MIN_INTERVAL : CoreCronDelegate.MIN_INTERVAL;
|
||||||
|
const 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 name Handler's name.
|
||||||
|
* @return 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 name Handler's name.
|
||||||
|
* @return Promise resolved with the handler's last execution time.
|
||||||
|
*/
|
||||||
|
protected async getHandlerLastExecutionTime(name: string): Promise<number> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
const id = this.getHandlerLastExecutionId(name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = await this.appDB.getRecord(this.CRON_TABLE, { id });
|
||||||
|
const time = parseInt(entry.value, 10);
|
||||||
|
|
||||||
|
return isNaN(time) ? 0 : time;
|
||||||
|
} catch (err) {
|
||||||
|
return 0; // Not set, return 0.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a handler uses network. Defaults to true.
|
||||||
|
*
|
||||||
|
* @param name Handler's name.
|
||||||
|
* @return 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 Whether it has at least 1 manual sync handler.
|
||||||
|
*/
|
||||||
|
hasManualSyncHandlers(): boolean {
|
||||||
|
for (const name in this.handlers) {
|
||||||
|
if (this.isHandlerManualSync(name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there is any sync handler registered.
|
||||||
|
*
|
||||||
|
* @return Whether it has at least 1 sync handler.
|
||||||
|
*/
|
||||||
|
hasSyncHandlers(): boolean {
|
||||||
|
for (const 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 name Handler's name.
|
||||||
|
* @return 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 name Handler's name.
|
||||||
|
* @return True if handler is a sync process or not defined, false otherwise.
|
||||||
|
*/
|
||||||
|
protected isHandlerSync(name: string): 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 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 '${handler.name}' is already registered.`);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Register handler '${handler.name}' in cron.`);
|
||||||
|
|
||||||
|
handler.running = false;
|
||||||
|
this.handlers[handler.name] = handler;
|
||||||
|
|
||||||
|
// Start the handler.
|
||||||
|
this.startHandler(handler.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a next execution for a handler.
|
||||||
|
*
|
||||||
|
* @param name Name of the handler.
|
||||||
|
* @param 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);
|
||||||
|
const 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 = window.setTimeout(() => {
|
||||||
|
delete this.handlers[name].timeout;
|
||||||
|
this.checkAndExecuteHandler(name).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
});
|
||||||
|
}, nextExecution);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a handler's last execution time.
|
||||||
|
*
|
||||||
|
* @param name Handler's name.
|
||||||
|
* @param time Time to set.
|
||||||
|
* @return Promise resolved when the execution time is saved.
|
||||||
|
*/
|
||||||
|
protected async setHandlerLastExecutionTime(name: string, time: number): Promise<any> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
const id = this.getHandlerLastExecutionId(name);
|
||||||
|
const entry = {
|
||||||
|
id,
|
||||||
|
value: time
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.appDB.insertRecord(this.CRON_TABLE, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start running a handler periodically.
|
||||||
|
*
|
||||||
|
* @param name Name of the handler.
|
||||||
|
*/
|
||||||
|
protected startHandler(name: string): 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 (const name in this.handlers) {
|
||||||
|
if (this.handlerUsesNetwork(name)) {
|
||||||
|
this.startHandler(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop running a handler periodically.
|
||||||
|
*
|
||||||
|
* @param name Name of the handler.
|
||||||
|
*/
|
||||||
|
protected stopHandler(name: string): void {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreCron extends makeSingleton(CoreCronDelegate) {}
|
|
@ -0,0 +1,85 @@
|
||||||
|
// (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 { SQLiteDB } from '@classes/sqlitedb';
|
||||||
|
import { SQLiteDBMock } from '@core/emulator/classes/sqlitedb';
|
||||||
|
import { makeSingleton, SQLite, Platform } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service allows interacting with the local database to store and retrieve data.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreDbProvider {
|
||||||
|
|
||||||
|
protected dbInstances = {};
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a database object.
|
||||||
|
*
|
||||||
|
* The database objects are cached statically.
|
||||||
|
*
|
||||||
|
* @param name DB name.
|
||||||
|
* @param forceNew True if it should always create a new instance.
|
||||||
|
* @return DB.
|
||||||
|
*/
|
||||||
|
getDB(name: string, forceNew?: boolean): SQLiteDB {
|
||||||
|
if (typeof this.dbInstances[name] === 'undefined' || forceNew) {
|
||||||
|
if (Platform.instance.is('cordova')) {
|
||||||
|
this.dbInstances[name] = new SQLiteDB(name);
|
||||||
|
} else {
|
||||||
|
this.dbInstances[name] = new SQLiteDBMock(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dbInstances[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a DB.
|
||||||
|
*
|
||||||
|
* @param name DB name.
|
||||||
|
* @return Promise resolved when the DB is deleted.
|
||||||
|
*/
|
||||||
|
deleteDB(name: string): Promise<any> {
|
||||||
|
let promise;
|
||||||
|
|
||||||
|
if (typeof this.dbInstances[name] != 'undefined') {
|
||||||
|
// Close the database first.
|
||||||
|
promise = this.dbInstances[name].close();
|
||||||
|
} else {
|
||||||
|
promise = Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise.then(() => {
|
||||||
|
const db = this.dbInstances[name];
|
||||||
|
delete this.dbInstances[name];
|
||||||
|
|
||||||
|
if (Platform.instance.is('cordova')) {
|
||||||
|
return SQLite.instance.deleteDatabase({
|
||||||
|
name,
|
||||||
|
location: 'default'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// In WebSQL we cannot delete the database, just empty it.
|
||||||
|
return db.emptyDatabase();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreDB extends makeSingleton(CoreDbProvider) {}
|
|
@ -0,0 +1,208 @@
|
||||||
|
// (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 { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { CoreLogger } from '@singletons/logger';
|
||||||
|
import { makeSingleton } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observer instance to stop listening to an event.
|
||||||
|
*/
|
||||||
|
export interface CoreEventObserver {
|
||||||
|
/**
|
||||||
|
* Stop the observer.
|
||||||
|
*/
|
||||||
|
off: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Service to send and listen to events.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreEventsProvider {
|
||||||
|
static SESSION_EXPIRED = 'session_expired';
|
||||||
|
static PASSWORD_CHANGE_FORCED = 'password_change_forced';
|
||||||
|
static USER_NOT_FULLY_SETUP = 'user_not_fully_setup';
|
||||||
|
static SITE_POLICY_NOT_AGREED = 'site_policy_not_agreed';
|
||||||
|
static LOGIN = 'login';
|
||||||
|
static LOGOUT = 'logout';
|
||||||
|
static LANGUAGE_CHANGED = 'language_changed';
|
||||||
|
static NOTIFICATION_SOUND_CHANGED = 'notification_sound_changed';
|
||||||
|
static SITE_ADDED = 'site_added';
|
||||||
|
static SITE_UPDATED = 'site_updated';
|
||||||
|
static SITE_DELETED = 'site_deleted';
|
||||||
|
static COMPLETION_MODULE_VIEWED = 'completion_module_viewed';
|
||||||
|
static USER_DELETED = 'user_deleted';
|
||||||
|
static PACKAGE_STATUS_CHANGED = 'package_status_changed';
|
||||||
|
static COURSE_STATUS_CHANGED = 'course_status_changed';
|
||||||
|
static SECTION_STATUS_CHANGED = 'section_status_changed';
|
||||||
|
static COMPONENT_FILE_ACTION = 'component_file_action';
|
||||||
|
static SITE_PLUGINS_LOADED = 'site_plugins_loaded';
|
||||||
|
static SITE_PLUGINS_COURSE_RESTRICT_UPDATED = 'site_plugins_course_restrict_updated';
|
||||||
|
static LOGIN_SITE_CHECKED = 'login_site_checked';
|
||||||
|
static LOGIN_SITE_UNCHECKED = 'login_site_unchecked';
|
||||||
|
static IAB_LOAD_START = 'inappbrowser_load_start';
|
||||||
|
static IAB_EXIT = 'inappbrowser_exit';
|
||||||
|
static APP_LAUNCHED_URL = 'app_launched_url'; // App opened with a certain URL (custom URL scheme).
|
||||||
|
static FILE_SHARED = 'file_shared';
|
||||||
|
static KEYBOARD_CHANGE = 'keyboard_change';
|
||||||
|
static CORE_LOADING_CHANGED = 'core_loading_changed';
|
||||||
|
static ORIENTATION_CHANGE = 'orientation_change';
|
||||||
|
static LOAD_PAGE_MAIN_MENU = 'load_page_main_menu';
|
||||||
|
static SEND_ON_ENTER_CHANGED = 'send_on_enter_changed';
|
||||||
|
static MAIN_MENU_OPEN = 'main_menu_open';
|
||||||
|
static SELECT_COURSE_TAB = 'select_course_tab';
|
||||||
|
static WS_CACHE_INVALIDATED = 'ws_cache_invalidated';
|
||||||
|
static SITE_STORAGE_DELETED = 'site_storage_deleted';
|
||||||
|
static FORM_ACTION = 'form_action';
|
||||||
|
static ACTIVITY_DATA_SENT = 'activity_data_sent';
|
||||||
|
|
||||||
|
protected logger: CoreLogger;
|
||||||
|
protected observables: { [s: string]: Subject<any> } = {};
|
||||||
|
protected uniqueEvents = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger = CoreLogger.getInstance('CoreEventsProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for a certain event. To stop listening to the event:
|
||||||
|
* let observer = eventsProvider.on('something', myCallBack);
|
||||||
|
* ...
|
||||||
|
* observer.off();
|
||||||
|
*
|
||||||
|
* @param eventName Name of the event to listen to.
|
||||||
|
* @param callBack Function to call when the event is triggered.
|
||||||
|
* @param siteId Site where to trigger the event. Undefined won't check the site.
|
||||||
|
* @return Observer to stop listening.
|
||||||
|
*/
|
||||||
|
on(eventName: string, callBack: (value: any) => void, siteId?: string): CoreEventObserver {
|
||||||
|
// If it's a unique event and has been triggered already, call the callBack.
|
||||||
|
// We don't need to create an observer because the event won't be triggered again.
|
||||||
|
if (this.uniqueEvents[eventName]) {
|
||||||
|
callBack(this.uniqueEvents[eventName].data);
|
||||||
|
|
||||||
|
// Return a fake observer to prevent errors.
|
||||||
|
return {
|
||||||
|
off: (): void => {
|
||||||
|
// Nothing to do.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`New observer listening to event '${eventName}'`);
|
||||||
|
|
||||||
|
if (typeof this.observables[eventName] == 'undefined') {
|
||||||
|
// No observable for this event, create a new one.
|
||||||
|
this.observables[eventName] = new Subject<any>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = this.observables[eventName].subscribe((value: any) => {
|
||||||
|
if (!siteId || value.siteId == siteId) {
|
||||||
|
callBack(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and return a CoreEventObserver.
|
||||||
|
return {
|
||||||
|
off: (): void => {
|
||||||
|
this.logger.debug(`Stop listening to event '${eventName}'`);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for several events. To stop listening to the events:
|
||||||
|
* let observer = eventsProvider.onMultiple(['something', 'another'], myCallBack);
|
||||||
|
* ...
|
||||||
|
* observer.off();
|
||||||
|
*
|
||||||
|
* @param eventNames Names of the events to listen to.
|
||||||
|
* @param callBack Function to call when any of the events is triggered.
|
||||||
|
* @param siteId Site where to trigger the event. Undefined won't check the site.
|
||||||
|
* @return Observer to stop listening.
|
||||||
|
*/
|
||||||
|
onMultiple(eventNames: string[], callBack: (value: any) => void, siteId?: string): CoreEventObserver {
|
||||||
|
|
||||||
|
const observers = eventNames.map((name) => {
|
||||||
|
return this.on(name, callBack, siteId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and return a CoreEventObserver.
|
||||||
|
return {
|
||||||
|
off: (): void => {
|
||||||
|
observers.forEach((observer) => {
|
||||||
|
observer.off();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an event, notifying all the observers.
|
||||||
|
*
|
||||||
|
* @param event Name of the event to trigger.
|
||||||
|
* @param data Data to pass to the observers.
|
||||||
|
* @param siteId Site where to trigger the event. Undefined means no Site.
|
||||||
|
*/
|
||||||
|
trigger(eventName: string, data?: any, siteId?: string): void {
|
||||||
|
this.logger.debug(`Event '${eventName}' triggered.`);
|
||||||
|
if (this.observables[eventName]) {
|
||||||
|
if (siteId) {
|
||||||
|
if (!data) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
data.siteId = siteId;
|
||||||
|
}
|
||||||
|
this.observables[eventName].next(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a unique event, notifying all the observers. If the event has already been triggered, don't do anything.
|
||||||
|
*
|
||||||
|
* @param event Name of the event to trigger.
|
||||||
|
* @param data Data to pass to the observers.
|
||||||
|
* @param siteId Site where to trigger the event. Undefined means no Site.
|
||||||
|
*/
|
||||||
|
triggerUnique(eventName: string, data: any, siteId?: string): void {
|
||||||
|
if (this.uniqueEvents[eventName]) {
|
||||||
|
this.logger.debug(`Unique event '${eventName}' ignored because it was already triggered.`);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Unique event '${eventName}' triggered.`);
|
||||||
|
|
||||||
|
if (siteId) {
|
||||||
|
if (!data) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
data.siteId = siteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the data so it can be passed to observers that register from now on.
|
||||||
|
this.uniqueEvents[eventName] = {
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Now pass the data to observers.
|
||||||
|
if (this.observables[eventName]) {
|
||||||
|
this.observables[eventName].next(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreEvents extends makeSingleton(CoreEventsProvider) {}
|
|
@ -0,0 +1,378 @@
|
||||||
|
// (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 { CoreApp } from '@services/app';
|
||||||
|
import { CoreFile } from '@services/file';
|
||||||
|
import { CoreFilepool } from '@services/filepool';
|
||||||
|
import { CoreSites } from '@services/sites';
|
||||||
|
import { CoreWS } from '@services/ws';
|
||||||
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { CoreUrlUtils } from '@services/utils/url';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { CoreConstants } from '@core/constants';
|
||||||
|
import { makeSingleton, Translate } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider to provide some helper functions regarding files and packages.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreFileHelperProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to open a file, downloading it if needed.
|
||||||
|
*
|
||||||
|
* @param file The file to download.
|
||||||
|
* @param component The component to link the file to.
|
||||||
|
* @param componentId An ID to use in conjunction with the component.
|
||||||
|
* @param state The file's state. If not provided, it will be calculated.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @param siteId The site ID. If not defined, current site.
|
||||||
|
* @return Resolved on success.
|
||||||
|
*/
|
||||||
|
async downloadAndOpenFile(file: any, component: string, componentId: string | number, state?: string,
|
||||||
|
onProgress?: (event: any) => any, siteId?: string): Promise<void> {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
const fileUrl = this.getFileUrl(file);
|
||||||
|
const timemodified = this.getFileTimemodified(file);
|
||||||
|
|
||||||
|
if (!this.isOpenableInApp(file)) {
|
||||||
|
await this.showConfirmOpenUnsupportedFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = await this.downloadFileIfNeeded(file, fileUrl, component, componentId, timemodified, state, onProgress, siteId);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CoreUrlUtils.instance.isLocalFileUrl(url)) {
|
||||||
|
/* In iOS, if we use the same URL in embedded browser and background download then the download only
|
||||||
|
downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */
|
||||||
|
url = url + '#moodlemobile-embedded';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await CoreUtils.instance.openOnlineFile(url);
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
// Error opening the file, some apps don't allow opening online files.
|
||||||
|
if (!CoreFile.instance.isAvailable()) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the state.
|
||||||
|
if (!state) {
|
||||||
|
state = await CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == CoreConstants.DOWNLOADING) {
|
||||||
|
throw new Error(Translate.instance.instant('core.erroropenfiledownloading'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === CoreConstants.NOT_DOWNLOADED) {
|
||||||
|
// File is not downloaded, download and then return the local URL.
|
||||||
|
url = await this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId);
|
||||||
|
} else {
|
||||||
|
// File is outdated and can't be opened in online, return the local URL.
|
||||||
|
url = await CoreFilepool.instance.getInternalUrlByUrl(siteId, fileUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoreUtils.instance.openFile(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file if it needs to be downloaded.
|
||||||
|
*
|
||||||
|
* @param file The file to download.
|
||||||
|
* @param fileUrl The file URL.
|
||||||
|
* @param component The component to link the file to.
|
||||||
|
* @param componentId An ID to use in conjunction with the component.
|
||||||
|
* @param timemodified The time this file was modified.
|
||||||
|
* @param state The file's state. If not provided, it will be calculated.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @param siteId The site ID. If not defined, current site.
|
||||||
|
* @return Resolved with the URL to use on success.
|
||||||
|
*/
|
||||||
|
protected downloadFileIfNeeded(file: any, fileUrl: string, component?: string, componentId?: string | number,
|
||||||
|
timemodified?: number, state?: string, onProgress?: (event: any) => any, siteId?: string): Promise<string> {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
return site.checkAndFixPluginfileURL(fileUrl);
|
||||||
|
}).then((fixedUrl) => {
|
||||||
|
|
||||||
|
if (CoreFile.instance.isAvailable()) {
|
||||||
|
let promise;
|
||||||
|
if (state) {
|
||||||
|
promise = Promise.resolve(state);
|
||||||
|
} else {
|
||||||
|
// Calculate the state.
|
||||||
|
promise = CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified);
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise.then((state) => {
|
||||||
|
// The file system is available.
|
||||||
|
const isWifi = CoreApp.instance.isWifi();
|
||||||
|
const isOnline = CoreApp.instance.isOnline();
|
||||||
|
|
||||||
|
if (state == CoreConstants.DOWNLOADED) {
|
||||||
|
// File is downloaded, get the local file URL.
|
||||||
|
return CoreFilepool.instance.getUrlByUrl(
|
||||||
|
siteId, fileUrl, component, componentId, timemodified, false, false, file);
|
||||||
|
} else {
|
||||||
|
if (!isOnline && !this.isStateDownloaded(state)) {
|
||||||
|
// Not downloaded and user is offline, reject.
|
||||||
|
return Promise.reject(Translate.instance.instant('core.networkerrormsg'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
// This call can take a while. Send a fake event to notify that we're doing some calculations.
|
||||||
|
onProgress({calculating: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoreFilepool.instance.shouldDownloadBeforeOpen(fixedUrl, file.filesize).then(() => {
|
||||||
|
if (state == CoreConstants.DOWNLOADING) {
|
||||||
|
// It's already downloading, stop.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download and then return the local URL.
|
||||||
|
return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId);
|
||||||
|
}, () => {
|
||||||
|
// Start the download if in wifi, but return the URL right away so the file is opened.
|
||||||
|
if (isWifi) {
|
||||||
|
this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isStateDownloaded(state) || isOnline) {
|
||||||
|
// Not downloaded or online, return the online URL.
|
||||||
|
return fixedUrl;
|
||||||
|
} else {
|
||||||
|
// Outdated but offline, so we return the local URL.
|
||||||
|
return CoreFilepool.instance.getUrlByUrl(
|
||||||
|
siteId, fileUrl, component, componentId, timemodified, false, false, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Use the online URL.
|
||||||
|
return fixedUrl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the file.
|
||||||
|
*
|
||||||
|
* @param fileUrl The file URL.
|
||||||
|
* @param component The component to link the file to.
|
||||||
|
* @param componentId An ID to use in conjunction with the component.
|
||||||
|
* @param timemodified The time this file was modified.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @param file The file to download.
|
||||||
|
* @param siteId The site ID. If not defined, current site.
|
||||||
|
* @return Resolved with internal URL on success, rejected otherwise.
|
||||||
|
*/
|
||||||
|
downloadFile(fileUrl: string, component?: string, componentId?: string | number, timemodified?: number,
|
||||||
|
onProgress?: (event: any) => any, file?: any, siteId?: string): Promise<string> {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
// Get the site and check if it can download files.
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
if (!site.canDownloadFiles()) {
|
||||||
|
return Promise.reject(Translate.instance.instant('core.cannotdownloadfiles'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoreFilepool.instance.downloadUrl(siteId, fileUrl, false, component, componentId,
|
||||||
|
timemodified, onProgress, undefined, file).catch((error) => {
|
||||||
|
|
||||||
|
// Download failed, check the state again to see if the file was downloaded before.
|
||||||
|
return CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified).then((state) => {
|
||||||
|
if (this.isStateDownloaded(state)) {
|
||||||
|
return CoreFilepool.instance.getInternalUrlByUrl(siteId, fileUrl);
|
||||||
|
} else {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the file's URL.
|
||||||
|
*
|
||||||
|
* @param file The file.
|
||||||
|
*/
|
||||||
|
getFileUrl(file: any): string {
|
||||||
|
return file.fileurl || file.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the file's timemodified.
|
||||||
|
*
|
||||||
|
* @param file The file.
|
||||||
|
*/
|
||||||
|
getFileTimemodified(file: any): number {
|
||||||
|
return file.timemodified || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a state is downloaded or outdated.
|
||||||
|
*
|
||||||
|
* @param state The state to check.
|
||||||
|
*/
|
||||||
|
isStateDownloaded(state: string): boolean {
|
||||||
|
return state === CoreConstants.DOWNLOADED || state === CoreConstants.OUTDATED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the file has to be opened in browser (external repository).
|
||||||
|
* The file must have a mimetype attribute.
|
||||||
|
*
|
||||||
|
* @param file The file to check.
|
||||||
|
* @return Whether the file should be opened in browser.
|
||||||
|
*/
|
||||||
|
shouldOpenInBrowser(file: any): boolean {
|
||||||
|
if (!file || !file.isexternalfile || !file.mimetype) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimetype = file.mimetype;
|
||||||
|
if (mimetype.indexOf('application/vnd.google-apps.') != -1) {
|
||||||
|
// Google Docs file, always open in browser.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.repositorytype == 'onedrive') {
|
||||||
|
// In OneDrive, open in browser the office docs
|
||||||
|
return mimetype.indexOf('application/vnd.openxmlformats-officedocument') != -1 ||
|
||||||
|
mimetype == 'text/plain' || mimetype == 'document/unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the total size of the given files.
|
||||||
|
*
|
||||||
|
* @param files The files to check.
|
||||||
|
* @return Total files size.
|
||||||
|
*/
|
||||||
|
async getTotalFilesSize(files: any[]): Promise<number> {
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
totalSize += await this.getFileSize(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the file size.
|
||||||
|
*
|
||||||
|
* @param file The file to check.
|
||||||
|
* @return File size.
|
||||||
|
*/
|
||||||
|
async getFileSize(file: any): Promise<number> {
|
||||||
|
if (file.filesize) {
|
||||||
|
return file.filesize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a remote file. First check if we have the file downloaded since it's more reliable.
|
||||||
|
if (file.filename && !file.name) {
|
||||||
|
try {
|
||||||
|
const siteId = CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
const path = await CoreFilepool.instance.getFilePathByUrl(siteId, file.fileurl);
|
||||||
|
const fileEntry = await CoreFile.instance.getFile(path);
|
||||||
|
const fileObject = await CoreFile.instance.getFileObjectFromFileEntry(fileEntry);
|
||||||
|
|
||||||
|
return fileObject.size;
|
||||||
|
} catch (error) {
|
||||||
|
// Error getting the file, maybe it's not downloaded. Get remote size.
|
||||||
|
const size = await CoreWS.instance.getRemoteFileSize(file.fileurl);
|
||||||
|
|
||||||
|
if (size === -1) {
|
||||||
|
throw new Error('Couldn\'t determine file size: ' + file.fileurl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a local file, get its size.
|
||||||
|
if (file.name) {
|
||||||
|
const fileObject = await CoreFile.instance.getFileObjectFromFileEntry(file);
|
||||||
|
|
||||||
|
return fileObject.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Couldn\'t determine file size: ' + file.fileurl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the file openable in app.
|
||||||
|
*
|
||||||
|
* @param file The file to check.
|
||||||
|
* @return bool.
|
||||||
|
*/
|
||||||
|
isOpenableInApp(file: {filename?: string, name?: string}): boolean {
|
||||||
|
const re = /(?:\.([^.]+))?$/;
|
||||||
|
|
||||||
|
const ext = re.exec(file.filename || file.name)[1];
|
||||||
|
|
||||||
|
return !this.isFileTypeExcludedInApp(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a confirm asking the user if we wants to open the file.
|
||||||
|
*
|
||||||
|
* @param onlyDownload Whether the user is only downloading the file, not opening it.
|
||||||
|
* @return Promise resolved if confirmed, rejected otherwise.
|
||||||
|
*/
|
||||||
|
showConfirmOpenUnsupportedFile(onlyDownload?: boolean): Promise<void> {
|
||||||
|
const message = Translate.instance.instant('core.cannotopeninapp' + (onlyDownload ? 'download' : ''));
|
||||||
|
const okButton = Translate.instance.instant(onlyDownload ? 'core.downloadfile' : 'core.openfile');
|
||||||
|
|
||||||
|
return CoreDomUtils.instance.showConfirm(message, undefined, okButton, undefined, { cssClass: 'core-modal-force-on-top' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the file type excluded to open in app.
|
||||||
|
*
|
||||||
|
* @param file The file to check.
|
||||||
|
* @return bool.
|
||||||
|
*/
|
||||||
|
isFileTypeExcludedInApp(fileType: string): boolean {
|
||||||
|
const currentSite = CoreSites.instance.getCurrentSite();
|
||||||
|
const fileTypeExcludeList = currentSite && currentSite.getStoredConfig('tool_mobile_filetypeexclusionlist');
|
||||||
|
|
||||||
|
if (!fileTypeExcludeList) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regEx = new RegExp('(,|^)' + fileType + '(,|$)', 'g');
|
||||||
|
|
||||||
|
return !!fileTypeExcludeList.match(regEx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreFileHelper extends makeSingleton(CoreFileHelperProvider) {}
|
|
@ -0,0 +1,152 @@
|
||||||
|
// (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 { makeSingleton } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to store some temporary data for file submission.
|
||||||
|
*
|
||||||
|
* It uses siteId and component name to index the files.
|
||||||
|
* Every component can provide a File area identifier to indentify every file list on the session.
|
||||||
|
* This value can be the activity id or a mix of name and numbers.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreFileSessionProvider {
|
||||||
|
protected files = {};
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a file to the session.
|
||||||
|
*
|
||||||
|
* @param component Component Name.
|
||||||
|
* @param id File area identifier.
|
||||||
|
* @param file File to add.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
addFile(component: string, id: string | number, file: any, siteId?: string): void {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
this.initFileArea(component, id, siteId);
|
||||||
|
|
||||||
|
this.files[siteId][component][id].push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear files stored in session.
|
||||||
|
*
|
||||||
|
* @param component Component Name.
|
||||||
|
* @param id File area identifier.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
clearFiles(component: string, id: string | number, siteId?: string): void {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
if (this.files[siteId] && this.files[siteId][component] && this.files[siteId][component][id]) {
|
||||||
|
this.files[siteId][component][id] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get files stored in session.
|
||||||
|
*
|
||||||
|
* @param component Component Name.
|
||||||
|
* @param id File area identifier.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Array of files in session.
|
||||||
|
*/
|
||||||
|
getFiles(component: string, id: string | number, siteId?: string): any[] {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
if (this.files[siteId] && this.files[siteId][component] && this.files[siteId][component][id]) {
|
||||||
|
return this.files[siteId][component][id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the filearea to store the file.
|
||||||
|
*
|
||||||
|
* @param component Component Name.
|
||||||
|
* @param id File area identifier.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
protected initFileArea(component: string, id: string | number, siteId?: string): void {
|
||||||
|
if (!this.files[siteId]) {
|
||||||
|
this.files[siteId] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.files[siteId][component]) {
|
||||||
|
this.files[siteId][component] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.files[siteId][component][id]) {
|
||||||
|
this.files[siteId][component][id] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a file stored in session.
|
||||||
|
*
|
||||||
|
* @param component Component Name.
|
||||||
|
* @param id File area identifier.
|
||||||
|
* @param file File to remove. The instance should be exactly the same as the one stored in session.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
removeFile(component: string, id: string | number, file: any, siteId?: string): void {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
if (this.files[siteId] && this.files[siteId][component] && this.files[siteId][component][id]) {
|
||||||
|
const position = this.files[siteId][component][id].indexOf(file);
|
||||||
|
if (position != -1) {
|
||||||
|
this.files[siteId][component][id].splice(position, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a file stored in session.
|
||||||
|
*
|
||||||
|
* @param component Component Name.
|
||||||
|
* @param id File area identifier.
|
||||||
|
* @param index Position of the file to remove.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
removeFileByIndex(component: string, id: string | number, index: number, siteId?: string): void {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
if (this.files[siteId] && this.files[siteId][component] && this.files[siteId][component][id] && index >= 0 &&
|
||||||
|
index < this.files[siteId][component][id].length) {
|
||||||
|
this.files[siteId][component][id].splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a group of files in the session.
|
||||||
|
*
|
||||||
|
* @param component Component Name.
|
||||||
|
* @param id File area identifier.
|
||||||
|
* @param newFiles Files to set.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
setFiles(component: string, id: string | number, newFiles: any[], siteId?: string): void {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
this.initFileArea(component, id, siteId);
|
||||||
|
|
||||||
|
this.files[siteId][component][id] = newFiles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreFileSession extends makeSingleton(CoreFileSessionProvider) {}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,144 @@
|
||||||
|
// (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 { Coordinates } from '@ionic-native/geolocation';
|
||||||
|
|
||||||
|
import { CoreApp } from '@services/app';
|
||||||
|
import { CoreError } from '@classes/error';
|
||||||
|
import { Geolocation, Diagnostic, makeSingleton } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CoreGeolocationProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user coordinates.
|
||||||
|
*
|
||||||
|
* @throws {CoreGeolocationError}
|
||||||
|
*/
|
||||||
|
async getCoordinates(): Promise<Coordinates> {
|
||||||
|
try {
|
||||||
|
await this.authorizeLocation();
|
||||||
|
await this.enableLocation();
|
||||||
|
|
||||||
|
const result = await Geolocation.instance.getCurrentPosition({
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.coords;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isCordovaPermissionDeniedError(error)) {
|
||||||
|
throw new CoreGeolocationError(CoreGeolocationErrorReason.PermissionDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that using device location has been authorized and ask for permission if it hasn't.
|
||||||
|
*
|
||||||
|
* @throws {CoreGeolocationError}
|
||||||
|
*/
|
||||||
|
async authorizeLocation(): Promise<void> {
|
||||||
|
await this.doAuthorizeLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that location is enabled and open settings to enable it if necessary.
|
||||||
|
*
|
||||||
|
* @throws {CoreGeolocationError}
|
||||||
|
*/
|
||||||
|
async enableLocation(): Promise<void> {
|
||||||
|
let locationEnabled = await Diagnostic.instance.isLocationEnabled();
|
||||||
|
|
||||||
|
if (locationEnabled) {
|
||||||
|
// Location is enabled.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CoreApp.instance.isIOS()) {
|
||||||
|
await Diagnostic.instance.switchToLocationSettings();
|
||||||
|
await CoreApp.instance.waitForResume(30000);
|
||||||
|
|
||||||
|
locationEnabled = await Diagnostic.instance.isLocationEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locationEnabled) {
|
||||||
|
throw new CoreGeolocationError(CoreGeolocationErrorReason.LocationNotEnabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursive implementation of authorizeLocation method, protected to avoid exposing the failOnDeniedOnce parameter.
|
||||||
|
*
|
||||||
|
* @param failOnDeniedOnce Throw an exception if the permission has been denied once.
|
||||||
|
* @throws {CoreGeolocationError}
|
||||||
|
*/
|
||||||
|
protected async doAuthorizeLocation(failOnDeniedOnce: boolean = false): Promise<void> {
|
||||||
|
const authorizationStatus = await Diagnostic.instance.getLocationAuthorizationStatus();
|
||||||
|
|
||||||
|
switch (authorizationStatus) {
|
||||||
|
// This constant is hard-coded because it is not declared in @ionic-native/diagnostic v4.
|
||||||
|
case 'DENIED_ONCE':
|
||||||
|
if (failOnDeniedOnce) {
|
||||||
|
throw new CoreGeolocationError(CoreGeolocationErrorReason.PermissionDenied);
|
||||||
|
}
|
||||||
|
// Fall through.
|
||||||
|
case Diagnostic.instance.permissionStatus.NOT_REQUESTED:
|
||||||
|
await Diagnostic.instance.requestLocationAuthorization();
|
||||||
|
await CoreApp.instance.waitForResume(500);
|
||||||
|
await this.doAuthorizeLocation(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
case Diagnostic.instance.permissionStatus.GRANTED:
|
||||||
|
case Diagnostic.instance.permissionStatus.GRANTED_WHEN_IN_USE:
|
||||||
|
// Location is authorized.
|
||||||
|
return;
|
||||||
|
case Diagnostic.instance.permissionStatus.DENIED:
|
||||||
|
default:
|
||||||
|
throw new CoreGeolocationError(CoreGeolocationErrorReason.PermissionDenied);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an error was caused by a PERMISSION_DENIED from the cordova plugin.
|
||||||
|
*
|
||||||
|
* @param error Error.
|
||||||
|
*/
|
||||||
|
protected isCordovaPermissionDeniedError(error?: any): boolean {
|
||||||
|
return error && 'code' in error && 'PERMISSION_DENIED' in error && error.code === error.PERMISSION_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreGeolocation extends makeSingleton(CoreGeolocationProvider) {}
|
||||||
|
|
||||||
|
export enum CoreGeolocationErrorReason {
|
||||||
|
PermissionDenied = 'permission-denied',
|
||||||
|
LocationNotEnabled = 'location-not-enabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreGeolocationError extends CoreError {
|
||||||
|
|
||||||
|
readonly reason: CoreGeolocationErrorReason;
|
||||||
|
|
||||||
|
constructor(reason: CoreGeolocationErrorReason) {
|
||||||
|
super(`GeolocationError: ${reason}`);
|
||||||
|
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,448 @@
|
||||||
|
// (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 { CoreSite, CoreSiteWSPreSets } from '@classes/site';
|
||||||
|
import { makeSingleton, Translate } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Service to handle groups.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreGroupsProvider {
|
||||||
|
// Group mode constants.
|
||||||
|
static NOGROUPS = 0;
|
||||||
|
static SEPARATEGROUPS = 1;
|
||||||
|
static VISIBLEGROUPS = 2;
|
||||||
|
protected ROOT_CACHE_KEY = 'mmGroups:';
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if group mode of an activity is enabled.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
|
||||||
|
* @return Promise resolved with true if the activity has groups, resolved with false otherwise.
|
||||||
|
*/
|
||||||
|
activityHasGroups(cmId: number, siteId?: string, ignoreCache?: boolean): Promise<boolean> {
|
||||||
|
return this.getActivityGroupMode(cmId, siteId, ignoreCache).then((groupmode) => {
|
||||||
|
return groupmode === CoreGroupsProvider.SEPARATEGROUPS || groupmode === CoreGroupsProvider.VISIBLEGROUPS;
|
||||||
|
}).catch(() => {
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the groups allowed in an activity.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param userId User ID. If not defined, use current user.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
|
||||||
|
* @return Promise resolved when the groups are retrieved.
|
||||||
|
*/
|
||||||
|
getActivityAllowedGroups(cmId: number, userId?: number, siteId?: string, ignoreCache?: boolean): Promise<any> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
cmid: cmId,
|
||||||
|
userid: userId,
|
||||||
|
};
|
||||||
|
const preSets: CoreSiteWSPreSets = {
|
||||||
|
cacheKey: this.getActivityAllowedGroupsCacheKey(cmId, userId),
|
||||||
|
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ignoreCache) {
|
||||||
|
preSets.getFromCache = false;
|
||||||
|
preSets.emergencyCache = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return site.read('core_group_get_activity_allowed_groups', params, preSets).then((response) => {
|
||||||
|
if (!response || !response.groups) {
|
||||||
|
return Promise.reject(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache key for group mode WS calls.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param userId User ID.
|
||||||
|
* @return Cache key.
|
||||||
|
*/
|
||||||
|
protected getActivityAllowedGroupsCacheKey(cmId: number, userId: number): string {
|
||||||
|
return this.ROOT_CACHE_KEY + 'allowedgroups:' + cmId + ':' + userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the groups allowed in an activity if they are allowed.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param userId User ID. If not defined, use current user.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
|
||||||
|
* @return Promise resolved when the groups are retrieved. If not allowed, empty array will be returned.
|
||||||
|
*/
|
||||||
|
getActivityAllowedGroupsIfEnabled(cmId: number, userId?: number, siteId?: string, ignoreCache?: boolean): Promise<any[]> {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
// Get real groupmode, in case it's forced by the course.
|
||||||
|
return this.activityHasGroups(cmId, siteId, ignoreCache).then((hasGroups) => {
|
||||||
|
if (hasGroups) {
|
||||||
|
// Get the groups available for the user.
|
||||||
|
return this.getActivityAllowedGroups(cmId, userId, siteId, ignoreCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get activity group info (group mode and list of groups).
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param addAllParts Deprecated.
|
||||||
|
* @param userId User ID. If not defined, use current user.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
|
||||||
|
* @return Promise resolved with the group info.
|
||||||
|
*/
|
||||||
|
getActivityGroupInfo(cmId: number, addAllParts?: boolean, userId?: number, siteId?: string, ignoreCache?: boolean)
|
||||||
|
: Promise<CoreGroupInfo> {
|
||||||
|
|
||||||
|
const groupInfo: CoreGroupInfo = {
|
||||||
|
groups: []
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.getActivityGroupMode(cmId, siteId, ignoreCache).then((groupMode) => {
|
||||||
|
groupInfo.separateGroups = groupMode === CoreGroupsProvider.SEPARATEGROUPS;
|
||||||
|
groupInfo.visibleGroups = groupMode === CoreGroupsProvider.VISIBLEGROUPS;
|
||||||
|
|
||||||
|
if (groupInfo.separateGroups || groupInfo.visibleGroups) {
|
||||||
|
return this.getActivityAllowedGroups(cmId, userId, siteId, ignoreCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: [],
|
||||||
|
canaccessallgroups: false
|
||||||
|
};
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.groups.length <= 0) {
|
||||||
|
groupInfo.separateGroups = false;
|
||||||
|
groupInfo.visibleGroups = false;
|
||||||
|
groupInfo.defaultGroupId = 0;
|
||||||
|
} else {
|
||||||
|
// The "canaccessallgroups" field was added in 3.4. Add all participants for visible groups in previous versions.
|
||||||
|
if (result.canaccessallgroups || (typeof result.canaccessallgroups == 'undefined' && groupInfo.visibleGroups)) {
|
||||||
|
groupInfo.groups.push({ id: 0, name: Translate.instance.instant('core.allparticipants') });
|
||||||
|
groupInfo.defaultGroupId = 0;
|
||||||
|
} else {
|
||||||
|
groupInfo.defaultGroupId = result.groups[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupInfo.groups = groupInfo.groups.concat(result.groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupInfo;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the group mode of an activity.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
|
||||||
|
* @return Promise resolved when the group mode is retrieved.
|
||||||
|
*/
|
||||||
|
getActivityGroupMode(cmId: number, siteId?: string, ignoreCache?: boolean): Promise<number> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
const params = {
|
||||||
|
cmid: cmId,
|
||||||
|
};
|
||||||
|
const preSets: CoreSiteWSPreSets = {
|
||||||
|
cacheKey: this.getActivityGroupModeCacheKey(cmId),
|
||||||
|
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ignoreCache) {
|
||||||
|
preSets.getFromCache = false;
|
||||||
|
preSets.emergencyCache = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return site.read('core_group_get_activity_groupmode', params, preSets).then((response) => {
|
||||||
|
if (!response || typeof response.groupmode == 'undefined') {
|
||||||
|
return Promise.reject(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.groupmode;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache key for group mode WS calls.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @return Cache key.
|
||||||
|
*/
|
||||||
|
protected getActivityGroupModeCacheKey(cmId: number): string {
|
||||||
|
return this.ROOT_CACHE_KEY + 'groupmode:' + cmId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user groups in all the user enrolled courses.
|
||||||
|
*
|
||||||
|
* @param siteId Site to get the groups from. If not defined, use current site.
|
||||||
|
* @return Promise resolved when the groups are retrieved.
|
||||||
|
*/
|
||||||
|
getAllUserGroups(siteId?: string): Promise<any[]> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
siteId = siteId || site.getId();
|
||||||
|
|
||||||
|
if (site.isVersionGreaterEqualThan('3.6')) {
|
||||||
|
return this.getUserGroupsInCourse(0, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @todo Get courses.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user groups in all the supplied courses.
|
||||||
|
*
|
||||||
|
* @param courses List of courses or course ids to get the groups from.
|
||||||
|
* @param siteId Site to get the groups from. If not defined, use current site.
|
||||||
|
* @param userId ID of the user. If not defined, use the userId related to siteId.
|
||||||
|
* @return Promise resolved when the groups are retrieved.
|
||||||
|
*/
|
||||||
|
getUserGroups(courses: any[], siteId?: string, userId?: number): Promise<any[]> {
|
||||||
|
// Get all courses one by one.
|
||||||
|
const promises = courses.map((course) => {
|
||||||
|
const courseId = typeof course == 'object' ? course.id : course;
|
||||||
|
|
||||||
|
return this.getUserGroupsInCourse(courseId, siteId, userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises).then((courseGroups) => {
|
||||||
|
return [].concat(...courseGroups);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user groups in a course.
|
||||||
|
*
|
||||||
|
* @param courseId ID of the course. 0 to get all enrolled courses groups (Moodle version > 3.6).
|
||||||
|
* @param siteId Site to get the groups from. If not defined, use current site.
|
||||||
|
* @param userId ID of the user. If not defined, use ID related to siteid.
|
||||||
|
* @return Promise resolved when the groups are retrieved.
|
||||||
|
*/
|
||||||
|
getUserGroupsInCourse(courseId: number, siteId?: string, userId?: number): Promise<any[]> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
const data = {
|
||||||
|
userid: userId,
|
||||||
|
courseid: courseId,
|
||||||
|
};
|
||||||
|
const preSets = {
|
||||||
|
cacheKey: this.getUserGroupsInCourseCacheKey(courseId, userId),
|
||||||
|
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
||||||
|
};
|
||||||
|
|
||||||
|
return site.read('core_group_get_course_user_groups', data, preSets).then((response) => {
|
||||||
|
if (response && response.groups) {
|
||||||
|
return response.groups;
|
||||||
|
} else {
|
||||||
|
return Promise.reject(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prefix cache key for user groups in course WS calls.
|
||||||
|
*
|
||||||
|
* @return Prefix Cache key.
|
||||||
|
*/
|
||||||
|
protected getUserGroupsInCoursePrefixCacheKey(): string {
|
||||||
|
return this.ROOT_CACHE_KEY + 'courseGroups:';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache key for user groups in course WS calls.
|
||||||
|
*
|
||||||
|
* @param courseId Course ID.
|
||||||
|
* @param userId User ID.
|
||||||
|
* @return Cache key.
|
||||||
|
*/
|
||||||
|
protected getUserGroupsInCourseCacheKey(courseId: number, userId: number): string {
|
||||||
|
return this.getUserGroupsInCoursePrefixCacheKey() + courseId + ':' + userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates activity allowed groups.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param userId User ID. If not defined, use current user.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the data is invalidated.
|
||||||
|
*/
|
||||||
|
invalidateActivityAllowedGroups(cmId: number, userId?: number, siteId?: string): Promise<any> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
return site.invalidateWsCacheForKey(this.getActivityAllowedGroupsCacheKey(cmId, userId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates activity group mode.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the data is invalidated.
|
||||||
|
*/
|
||||||
|
invalidateActivityGroupMode(cmId: number, siteId?: string): Promise<any> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
return site.invalidateWsCacheForKey(this.getActivityGroupModeCacheKey(cmId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates all activity group info: mode and allowed groups.
|
||||||
|
*
|
||||||
|
* @param cmId Course module ID.
|
||||||
|
* @param userId User ID. If not defined, use current user.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the data is invalidated.
|
||||||
|
*/
|
||||||
|
invalidateActivityGroupInfo(cmId: number, userId?: number, siteId?: string): Promise<any> {
|
||||||
|
const promises = [];
|
||||||
|
promises.push(this.invalidateActivityAllowedGroups(cmId, userId, siteId));
|
||||||
|
promises.push(this.invalidateActivityGroupMode(cmId, siteId));
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates user groups in all user enrolled courses.
|
||||||
|
*
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the data is invalidated.
|
||||||
|
*/
|
||||||
|
invalidateAllUserGroups(siteId?: string): Promise<any> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
if (site.isVersionGreaterEqualThan('3.6')) {
|
||||||
|
return this.invalidateUserGroupsInCourse(0, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return site.invalidateWsCacheForKeyStartingWith(this.getUserGroupsInCoursePrefixCacheKey());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates user groups in courses.
|
||||||
|
*
|
||||||
|
* @param courses List of courses or course ids.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined, use current user.
|
||||||
|
* @return Promise resolved when the data is invalidated.
|
||||||
|
*/
|
||||||
|
invalidateUserGroups(courses: any[], siteId?: string, userId?: number): Promise<any> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
const promises = courses.map((course) => {
|
||||||
|
const courseId = typeof course == 'object' ? course.id : course;
|
||||||
|
|
||||||
|
return this.invalidateUserGroupsInCourse(courseId, site.id, userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates user groups in course.
|
||||||
|
*
|
||||||
|
* @param courseId ID of the course. 0 to get all enrolled courses groups (Moodle version > 3.6).
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined, use current user.
|
||||||
|
* @return Promise resolved when the data is invalidated.
|
||||||
|
*/
|
||||||
|
invalidateUserGroupsInCourse(courseId: number, siteId?: string, userId?: number): Promise<any> {
|
||||||
|
return CoreSites.instance.getSite(siteId).then((site) => {
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
return site.invalidateWsCacheForKey(this.getUserGroupsInCourseCacheKey(courseId, userId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a group ID. If the group is not visible by the user, it will return the first group ID.
|
||||||
|
*
|
||||||
|
* @param groupId Group ID to validate.
|
||||||
|
* @param groupInfo Group info.
|
||||||
|
* @return Group ID to use.
|
||||||
|
*/
|
||||||
|
validateGroupId(groupId: number, groupInfo: CoreGroupInfo): number {
|
||||||
|
if (groupId > 0 && groupInfo && groupInfo.groups && groupInfo.groups.length > 0) {
|
||||||
|
// Check if the group is in the list of groups.
|
||||||
|
if (groupInfo.groups.some((group) => groupId == group.id)) {
|
||||||
|
return groupId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupInfo.defaultGroupId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreGroups extends makeSingleton(CoreGroupsProvider) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group info for an activity.
|
||||||
|
*/
|
||||||
|
export type CoreGroupInfo = {
|
||||||
|
/**
|
||||||
|
* List of groups.
|
||||||
|
*/
|
||||||
|
groups?: any[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether it's separate groups.
|
||||||
|
*/
|
||||||
|
separateGroups?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether it's visible groups.
|
||||||
|
*/
|
||||||
|
visibleGroups?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group ID to use by default. If all participants is visible, 0 will be used. First group ID otherwise.
|
||||||
|
*/
|
||||||
|
defaultGroupId?: number;
|
||||||
|
};
|
|
@ -0,0 +1,451 @@
|
||||||
|
// (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 CoreConfigConstants from '@app/config.json';
|
||||||
|
import { CoreApp, CoreAppProvider } from '@services/app';
|
||||||
|
import { CoreConfig } from '@services/config';
|
||||||
|
import { makeSingleton, Translate, Platform, Globalization } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
import * as moment from 'moment';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Service to handle language features, like changing the current language.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreLangProvider {
|
||||||
|
protected fallbackLanguage = 'en'; // Always use English as fallback language since it contains all strings.
|
||||||
|
protected defaultLanguage = CoreConfigConstants.default_lang || 'en'; // Lang to use if device lang not valid or is forced.
|
||||||
|
protected currentLanguage: string; // Save current language in a variable to speed up the get function.
|
||||||
|
protected customStrings = {}; // Strings defined using the admin tool.
|
||||||
|
protected customStringsRaw: string;
|
||||||
|
protected sitePluginsStrings = {}; // Strings defined by site plugins.
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Set fallback language and language to use until the app determines the right language to use.
|
||||||
|
Translate.instance.setDefaultLang(this.fallbackLanguage);
|
||||||
|
Translate.instance.use(this.defaultLanguage);
|
||||||
|
|
||||||
|
Platform.instance.ready().then(() => {
|
||||||
|
if (CoreAppProvider.isAutomated()) {
|
||||||
|
// Force current language to English when Behat is running.
|
||||||
|
this.changeCurrentLanguage('en');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getCurrentLanguage().then((language) => {
|
||||||
|
this.changeCurrentLanguage(language);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Translate.instance.onLangChange.subscribe((event: any) => {
|
||||||
|
// @todo: Set platform lang and dir.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a set of site plugins strings for a certain language.
|
||||||
|
*
|
||||||
|
* @param lang The language where to add the strings.
|
||||||
|
* @param strings Object with the strings to add.
|
||||||
|
* @param prefix A prefix to add to all keys.
|
||||||
|
*/
|
||||||
|
addSitePluginsStrings(lang: string, strings: any, prefix?: string): void {
|
||||||
|
lang = lang.replace(/_/g, '-'); // Use the app format instead of Moodle format.
|
||||||
|
|
||||||
|
// Initialize structure if it doesn't exist.
|
||||||
|
if (!this.sitePluginsStrings[lang]) {
|
||||||
|
this.sitePluginsStrings[lang] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in strings) {
|
||||||
|
const prefixedKey = prefix + key;
|
||||||
|
let value = strings[key];
|
||||||
|
|
||||||
|
if (this.customStrings[lang] && this.customStrings[lang][prefixedKey]) {
|
||||||
|
// This string is overridden by a custom string, ignore it.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the way to access subproperties.
|
||||||
|
value = value.replace(/\$a->/gm, '$a.');
|
||||||
|
// Add another curly bracket to string params ({$a} -> {{$a}}).
|
||||||
|
value = value.replace(/{([^ ]+)}/gm, '{{$1}}');
|
||||||
|
// Make sure we didn't add to many brackets in some case.
|
||||||
|
value = value.replace(/{{{([^ ]+)}}}/gm, '{{$1}}');
|
||||||
|
|
||||||
|
// Load the string.
|
||||||
|
this.loadString(this.sitePluginsStrings, lang, prefixedKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize a string (make the first letter uppercase).
|
||||||
|
* We cannot use a function from text utils because it would cause a circular dependency.
|
||||||
|
*
|
||||||
|
* @param value String to capitalize.
|
||||||
|
* @return Capitalized string.
|
||||||
|
*/
|
||||||
|
protected capitalize(value: string): string {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change current language.
|
||||||
|
*
|
||||||
|
* @param language New language to use.
|
||||||
|
* @return Promise resolved when the change is finished.
|
||||||
|
*/
|
||||||
|
changeCurrentLanguage(language: string): Promise<any> {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
// Change the language, resolving the promise when we receive the first value.
|
||||||
|
promises.push(new Promise((resolve, reject): void => {
|
||||||
|
const subscription = Translate.instance.use(language).subscribe((data) => {
|
||||||
|
// It's a language override, load the original one first.
|
||||||
|
const fallbackLang = Translate.instance.instant('core.parentlanguage');
|
||||||
|
|
||||||
|
if (fallbackLang != '' && fallbackLang != 'core.parentlanguage' && fallbackLang != language) {
|
||||||
|
const fallbackSubs = Translate.instance.use(fallbackLang).subscribe((fallbackData) => {
|
||||||
|
data = Object.assign(fallbackData, data);
|
||||||
|
resolve(data);
|
||||||
|
|
||||||
|
// Data received, unsubscribe. Use a timeout because we can receive a value immediately.
|
||||||
|
setTimeout(() => {
|
||||||
|
fallbackSubs.unsubscribe();
|
||||||
|
});
|
||||||
|
}, (error) => {
|
||||||
|
// Resolve with the original language.
|
||||||
|
resolve(data);
|
||||||
|
|
||||||
|
// Error received, unsubscribe. Use a timeout because we can receive a value immediately.
|
||||||
|
setTimeout(() => {
|
||||||
|
fallbackSubs.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data received, unsubscribe. Use a timeout because we can receive a value immediately.
|
||||||
|
setTimeout(() => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
}, (error) => {
|
||||||
|
reject(error);
|
||||||
|
|
||||||
|
// Error received, unsubscribe. Use a timeout because we can receive a value immediately.
|
||||||
|
setTimeout(() => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Change the config.
|
||||||
|
promises.push(CoreConfig.instance.set('current_language', language));
|
||||||
|
|
||||||
|
// Use british english when parent english is loaded.
|
||||||
|
moment.locale(language == 'en' ? 'en-gb' : language);
|
||||||
|
|
||||||
|
// @todo: Set data for ion-datetime.
|
||||||
|
|
||||||
|
this.currentLanguage = language;
|
||||||
|
|
||||||
|
return Promise.all(promises).finally(() => {
|
||||||
|
// Load the custom and site plugins strings for the language.
|
||||||
|
if (this.loadLangStrings(this.customStrings, language) || this.loadLangStrings(this.sitePluginsStrings, language)) {
|
||||||
|
// Some lang strings have changed, emit an event to update the pipes.
|
||||||
|
Translate.instance.onLangChange.emit({lang: language, translations: Translate.instance.translations[language]});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear current custom strings.
|
||||||
|
*/
|
||||||
|
clearCustomStrings(): void {
|
||||||
|
this.unloadStrings(this.customStrings);
|
||||||
|
this.customStrings = {};
|
||||||
|
this.customStringsRaw = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear current site plugins strings.
|
||||||
|
*/
|
||||||
|
clearSitePluginsStrings(): void {
|
||||||
|
this.unloadStrings(this.sitePluginsStrings);
|
||||||
|
this.sitePluginsStrings = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all current custom strings.
|
||||||
|
*
|
||||||
|
* @return Custom strings.
|
||||||
|
*/
|
||||||
|
getAllCustomStrings(): any {
|
||||||
|
return this.customStrings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all current site plugins strings.
|
||||||
|
*
|
||||||
|
* @return Site plugins strings.
|
||||||
|
*/
|
||||||
|
getAllSitePluginsStrings(): any {
|
||||||
|
return this.sitePluginsStrings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current language.
|
||||||
|
*
|
||||||
|
* @return Promise resolved with the current language.
|
||||||
|
*/
|
||||||
|
getCurrentLanguage(): Promise<string> {
|
||||||
|
|
||||||
|
if (typeof this.currentLanguage != 'undefined') {
|
||||||
|
return Promise.resolve(this.currentLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current language from config (user might have changed it).
|
||||||
|
return CoreConfig.instance.get('current_language').then((language) => {
|
||||||
|
return language;
|
||||||
|
}).catch(() => {
|
||||||
|
// User hasn't defined a language. If default language is forced, use it.
|
||||||
|
if (CoreConfigConstants.default_lang && CoreConfigConstants.forcedefaultlanguage) {
|
||||||
|
return CoreConfigConstants.default_lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// No forced language, try to get current language from cordova globalization.
|
||||||
|
return Globalization.instance.getPreferredLanguage().then((result) => {
|
||||||
|
let language = result.value.toLowerCase();
|
||||||
|
if (language.indexOf('-') > -1) {
|
||||||
|
// Language code defined by locale has a dash, like en-US or es-ES. Check if it's supported.
|
||||||
|
if (CoreConfigConstants.languages && typeof CoreConfigConstants.languages[language] == 'undefined') {
|
||||||
|
// Code is NOT supported. Fallback to language without dash. E.g. 'en-US' would fallback to 'en'.
|
||||||
|
language = language.substr(0, language.indexOf('-'));
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof CoreConfigConstants.languages[language] == 'undefined') {
|
||||||
|
// Language not supported, use default language.
|
||||||
|
return this.defaultLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return language;
|
||||||
|
}).catch(() => {
|
||||||
|
// Error getting locale. Use default language.
|
||||||
|
return this.defaultLanguage;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Error getting locale. Use default language.
|
||||||
|
return Promise.resolve(this.defaultLanguage);
|
||||||
|
}
|
||||||
|
}).then((language) => {
|
||||||
|
this.currentLanguage = language; // Save it for later.
|
||||||
|
|
||||||
|
return language;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default language.
|
||||||
|
*
|
||||||
|
* @return Default language.
|
||||||
|
*/
|
||||||
|
getDefaultLanguage(): string {
|
||||||
|
return this.defaultLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the fallback language.
|
||||||
|
*
|
||||||
|
* @return Fallback language.
|
||||||
|
*/
|
||||||
|
getFallbackLanguage(): string {
|
||||||
|
return this.fallbackLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full list of translations for a certain language.
|
||||||
|
*
|
||||||
|
* @param lang The language to check.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
getTranslationTable(lang: string): Promise<any> {
|
||||||
|
// Create a promise to convert the observable into a promise.
|
||||||
|
return new Promise((resolve, reject): void => {
|
||||||
|
const observer = Translate.instance.getTranslation(lang).subscribe((table) => {
|
||||||
|
resolve(table);
|
||||||
|
observer.unsubscribe();
|
||||||
|
}, (err) => {
|
||||||
|
reject(err);
|
||||||
|
observer.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load certain custom strings.
|
||||||
|
*
|
||||||
|
* @param strings Custom strings to load (tool_mobile_customlangstrings).
|
||||||
|
*/
|
||||||
|
loadCustomStrings(strings: string): void {
|
||||||
|
if (strings == this.customStringsRaw) {
|
||||||
|
// Strings haven't changed, stop.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset current values.
|
||||||
|
this.clearCustomStrings();
|
||||||
|
|
||||||
|
if (!strings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLangChanged = false;
|
||||||
|
|
||||||
|
const list: string[] = strings.split(/(?:\r\n|\r|\n)/);
|
||||||
|
list.forEach((entry: string) => {
|
||||||
|
const values: string[] = entry.split('|');
|
||||||
|
let lang: string;
|
||||||
|
|
||||||
|
if (values.length < 3) {
|
||||||
|
// Not enough data, ignore the entry.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lang = values[2].replace(/_/g, '-'); // Use the app format instead of Moodle format.
|
||||||
|
|
||||||
|
if (lang == this.currentLanguage) {
|
||||||
|
currentLangChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.customStrings[lang]) {
|
||||||
|
this.customStrings[lang] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert old keys format to new one.
|
||||||
|
const key = values[0].replace(/^mm\.core/, 'core').replace(/^mm\./, 'core.').replace(/^mma\./, 'addon.')
|
||||||
|
.replace(/^core\.sidemenu/, 'core.mainmenu').replace(/^addon\.grades/, 'core.grades')
|
||||||
|
.replace(/^addon\.participants/, 'core.user');
|
||||||
|
|
||||||
|
this.loadString(this.customStrings, lang, key, values[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.customStringsRaw = strings;
|
||||||
|
|
||||||
|
if (currentLangChanged) {
|
||||||
|
// Some lang strings have changed, emit an event to update the pipes.
|
||||||
|
Translate.instance.onLangChange.emit({
|
||||||
|
lang: this.currentLanguage,
|
||||||
|
translations: Translate.instance.translations[this.currentLanguage]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load custom strings for a certain language that weren't loaded because the language wasn't active.
|
||||||
|
*
|
||||||
|
* @param langObject The object with the strings to load.
|
||||||
|
* @param lang Language to load.
|
||||||
|
* @return Whether the translation table was modified.
|
||||||
|
*/
|
||||||
|
loadLangStrings(langObject: any, lang: string): boolean {
|
||||||
|
let langApplied = false;
|
||||||
|
|
||||||
|
if (langObject[lang]) {
|
||||||
|
for (const key in langObject[lang]) {
|
||||||
|
const entry = langObject[lang][key];
|
||||||
|
|
||||||
|
if (!entry.applied) {
|
||||||
|
// Store the original value of the string.
|
||||||
|
entry.original = Translate.instance.translations[lang][key];
|
||||||
|
|
||||||
|
// Store the string in the translations table.
|
||||||
|
Translate.instance.translations[lang][key] = entry.value;
|
||||||
|
|
||||||
|
entry.applied = true;
|
||||||
|
langApplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return langApplied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a string in a certain lang object and in the translate table if the lang is loaded.
|
||||||
|
*
|
||||||
|
* @param langObject The object where to store the lang.
|
||||||
|
* @param lang Language code.
|
||||||
|
* @param key String key.
|
||||||
|
* @param value String value.
|
||||||
|
*/
|
||||||
|
loadString(langObject: any, lang: string, key: string, value: string): void {
|
||||||
|
lang = lang.replace(/_/g, '-'); // Use the app format instead of Moodle format.
|
||||||
|
|
||||||
|
if (Translate.instance.translations[lang]) {
|
||||||
|
// The language is loaded.
|
||||||
|
// Store the original value of the string.
|
||||||
|
langObject[lang][key] = {
|
||||||
|
original: Translate.instance.translations[lang][key],
|
||||||
|
value,
|
||||||
|
applied: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the string in the translations table.
|
||||||
|
Translate.instance.translations[lang][key] = value;
|
||||||
|
} else {
|
||||||
|
// The language isn't loaded.
|
||||||
|
// Save it in our object but not in the translations table, it will be loaded when the lang is loaded.
|
||||||
|
langObject[lang][key] = {
|
||||||
|
value,
|
||||||
|
applied: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unload custom or site plugin strings, removing them from the translations table.
|
||||||
|
*
|
||||||
|
* @param strings Strings to unload.
|
||||||
|
*/
|
||||||
|
protected unloadStrings(strings: any): void {
|
||||||
|
// Iterate over all languages and strings.
|
||||||
|
for (const lang in strings) {
|
||||||
|
if (!Translate.instance.translations[lang]) {
|
||||||
|
// Language isn't loaded, nothing to unload.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const langStrings = strings[lang];
|
||||||
|
for (const key in langStrings) {
|
||||||
|
const entry = langStrings[key];
|
||||||
|
if (entry.original) {
|
||||||
|
// The string had a value, restore it.
|
||||||
|
Translate.instance.translations[lang][key] = entry.original;
|
||||||
|
} else {
|
||||||
|
// The string didn't exist, delete it.
|
||||||
|
delete Translate.instance.translations[lang][key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreLang extends makeSingleton(CoreLangProvider) {}
|
|
@ -0,0 +1,697 @@
|
||||||
|
// (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 { Subject, Subscription } from 'rxjs';
|
||||||
|
import { ILocalNotification } from '@ionic-native/local-notifications';
|
||||||
|
|
||||||
|
import { CoreApp, CoreAppSchema } from '@services/app';
|
||||||
|
import { CoreConfig } from '@services/config';
|
||||||
|
import { CoreEvents, CoreEventsProvider } from '@services/events';
|
||||||
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { SQLiteDB } from '@classes/sqlitedb';
|
||||||
|
import { CoreQueueRunner } from '@classes/queue-runner';
|
||||||
|
import { CoreConstants } from '@core/constants';
|
||||||
|
import CoreConfigConstants from '@app/config.json';
|
||||||
|
import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push, Device } from '@singletons/core.singletons';
|
||||||
|
import { CoreLogger } from '@singletons/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to handle local notifications.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreLocalNotificationsProvider {
|
||||||
|
// Variables for the database.
|
||||||
|
protected SITES_TABLE = 'notification_sites'; // Store to asigne unique codes to each site.
|
||||||
|
protected COMPONENTS_TABLE = 'notification_components'; // Store to asigne unique codes to each component.
|
||||||
|
protected TRIGGERED_TABLE = 'notifications_triggered'; // Store to prevent re-triggering notifications.
|
||||||
|
protected tablesSchema: CoreAppSchema = {
|
||||||
|
name: 'CoreLocalNotificationsProvider',
|
||||||
|
version: 1,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: this.SITES_TABLE,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'TEXT',
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'code',
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: this.COMPONENTS_TABLE,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'TEXT',
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'code',
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: this.TRIGGERED_TABLE,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'INTEGER',
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'at',
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
protected logger: CoreLogger;
|
||||||
|
protected appDB: SQLiteDB;
|
||||||
|
protected dbReady: Promise<any>; // Promise resolved when the app DB is initialized.
|
||||||
|
protected codes: { [s: string]: number } = {};
|
||||||
|
protected codeRequestsQueue = {};
|
||||||
|
protected observables = {};
|
||||||
|
protected currentNotification = {
|
||||||
|
title: '',
|
||||||
|
texts: [],
|
||||||
|
ids: [],
|
||||||
|
timeouts: []
|
||||||
|
};
|
||||||
|
protected triggerSubscription: Subscription;
|
||||||
|
protected clickSubscription: Subscription;
|
||||||
|
protected clearSubscription: Subscription;
|
||||||
|
protected cancelSubscription: Subscription;
|
||||||
|
protected addSubscription: Subscription;
|
||||||
|
protected updateSubscription: Subscription;
|
||||||
|
protected queueRunner: CoreQueueRunner; // Queue to decrease the number of concurrent calls to the plugin (see MOBILE-3477).
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
|
||||||
|
this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider');
|
||||||
|
this.queueRunner = new CoreQueueRunner(10);
|
||||||
|
this.appDB = CoreApp.instance.getDB();
|
||||||
|
this.dbReady = CoreApp.instance.createTablesFromSchema(this.tablesSchema).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
});
|
||||||
|
|
||||||
|
Platform.instance.ready().then(() => {
|
||||||
|
// Listen to events.
|
||||||
|
this.triggerSubscription = LocalNotifications.instance.on('trigger').subscribe((notification: ILocalNotification) => {
|
||||||
|
this.trigger(notification);
|
||||||
|
|
||||||
|
this.handleEvent('trigger', notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clickSubscription = LocalNotifications.instance.on('click').subscribe((notification: ILocalNotification) => {
|
||||||
|
this.handleEvent('click', notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clearSubscription = LocalNotifications.instance.on('clear').subscribe((notification: ILocalNotification) => {
|
||||||
|
this.handleEvent('clear', notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cancelSubscription = LocalNotifications.instance.on('cancel').subscribe((notification: ILocalNotification) => {
|
||||||
|
this.handleEvent('cancel', notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addSubscription = LocalNotifications.instance.on('schedule').subscribe((notification: ILocalNotification) => {
|
||||||
|
this.handleEvent('schedule', notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateSubscription = LocalNotifications.instance.on('update').subscribe((notification: ILocalNotification) => {
|
||||||
|
this.handleEvent('update', notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the default channel for local notifications.
|
||||||
|
this.createDefaultChannel();
|
||||||
|
|
||||||
|
Translate.instance.onLangChange.subscribe((event: any) => {
|
||||||
|
// Update the channel name.
|
||||||
|
this.createDefaultChannel();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
CoreEvents.instance.on(CoreEventsProvider.SITE_DELETED, (site) => {
|
||||||
|
if (site) {
|
||||||
|
this.cancelSiteNotifications(site.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a local notification.
|
||||||
|
*
|
||||||
|
* @param id Notification id.
|
||||||
|
* @param component Component of the notification.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when the notification is cancelled.
|
||||||
|
*/
|
||||||
|
async cancel(id: number, component: string, siteId: string): Promise<void> {
|
||||||
|
const uniqueId = await this.getUniqueNotificationId(id, component, siteId);
|
||||||
|
|
||||||
|
const queueId = 'cancel-' + uniqueId;
|
||||||
|
|
||||||
|
await this.queueRunner.run(queueId, () => LocalNotifications.instance.cancel(uniqueId), {
|
||||||
|
allowRepeated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all the scheduled notifications belonging to a certain site.
|
||||||
|
*
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when the notifications are cancelled.
|
||||||
|
*/
|
||||||
|
async cancelSiteNotifications(siteId: string): Promise<void> {
|
||||||
|
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
return;
|
||||||
|
} else if (!siteId) {
|
||||||
|
throw new Error('No site ID supplied.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduled = await this.getAllScheduled();
|
||||||
|
|
||||||
|
const ids = [];
|
||||||
|
const queueId = 'cancelSiteNotifications-' + siteId;
|
||||||
|
|
||||||
|
scheduled.forEach((notif) => {
|
||||||
|
notif.data = this.parseNotificationData(notif.data);
|
||||||
|
|
||||||
|
if (typeof notif.data == 'object' && notif.data.siteId === siteId) {
|
||||||
|
ids.push(notif.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.queueRunner.run(queueId, () => LocalNotifications.instance.cancel(ids), {
|
||||||
|
allowRepeated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether sound can be disabled for notifications.
|
||||||
|
*
|
||||||
|
* @return Whether sound can be disabled for notifications.
|
||||||
|
*/
|
||||||
|
canDisableSound(): boolean {
|
||||||
|
// Only allow disabling sound in Android 7 or lower. In iOS and Android 8+ it can easily be done with system settings.
|
||||||
|
return this.isAvailable() && !CoreApp.instance.isDesktop() && CoreApp.instance.isAndroid() &&
|
||||||
|
Device.instance.version && Number(Device.instance.version.split('.')[0]) < 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the default channel. It is used to change the name.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected createDefaultChannel(): Promise<any> {
|
||||||
|
if (!CoreApp.instance.isAndroid()) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Push.instance.createChannel({
|
||||||
|
id: 'default-channel-id',
|
||||||
|
description: Translate.instance.instant('addon.calendar.calendarreminders'),
|
||||||
|
importance: 4
|
||||||
|
}).catch((error) => {
|
||||||
|
this.logger.error('Error changing channel name', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all scheduled notifications.
|
||||||
|
*
|
||||||
|
* @return Promise resolved with the notifications.
|
||||||
|
*/
|
||||||
|
protected getAllScheduled(): Promise<ILocalNotification[]> {
|
||||||
|
return this.queueRunner.run('allScheduled', () => LocalNotifications.instance.getAllScheduled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a code to create unique notifications. If there's no code assigned, create a new one.
|
||||||
|
*
|
||||||
|
* @param table Table to search in local DB.
|
||||||
|
* @param id ID of the element to get its code.
|
||||||
|
* @return Promise resolved when the code is retrieved.
|
||||||
|
*/
|
||||||
|
protected async getCode(table: string, id: string): Promise<number> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
const key = table + '#' + id;
|
||||||
|
|
||||||
|
// Check if the code is already in memory.
|
||||||
|
if (typeof this.codes[key] != 'undefined') {
|
||||||
|
return this.codes[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if we already have a code stored for that ID.
|
||||||
|
const entry = await this.appDB.getRecord(table, { id: id });
|
||||||
|
this.codes[key] = entry.code;
|
||||||
|
|
||||||
|
return entry.code;
|
||||||
|
} catch (err) {
|
||||||
|
// No code stored for that ID. Create a new code for it.
|
||||||
|
const entries = await this.appDB.getRecords(table, undefined, 'code DESC');
|
||||||
|
let newCode = 0;
|
||||||
|
if (entries.length > 0) {
|
||||||
|
newCode = entries[0].code + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.appDB.insertRecord(table, { id: id, code: newCode });
|
||||||
|
this.codes[key] = newCode;
|
||||||
|
|
||||||
|
return newCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a notification component code to be used.
|
||||||
|
* If it's the first time this component is used to send notifications, create a new code for it.
|
||||||
|
*
|
||||||
|
* @param component Component name.
|
||||||
|
* @return Promise resolved when the component code is retrieved.
|
||||||
|
*/
|
||||||
|
protected getComponentCode(component: string): Promise<number> {
|
||||||
|
return this.requestCode(this.COMPONENTS_TABLE, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a site code to be used.
|
||||||
|
* If it's the first time this site is used to send notifications, create a new code for it.
|
||||||
|
*
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when the site code is retrieved.
|
||||||
|
*/
|
||||||
|
protected getSiteCode(siteId: string): Promise<number> {
|
||||||
|
return this.requestCode(this.SITES_TABLE, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique notification ID, trying to prevent collisions. Generated ID must be a Number (Android).
|
||||||
|
* The generated ID shouldn't be higher than 2147483647 or it's going to cause problems in Android.
|
||||||
|
* This function will prevent collisions and keep the number under Android limit if:
|
||||||
|
* -User has used less than 21 sites.
|
||||||
|
* -There are less than 11 components.
|
||||||
|
* -The notificationId passed as parameter is lower than 10000000.
|
||||||
|
*
|
||||||
|
* @param notificationId Notification ID.
|
||||||
|
* @param component Component triggering the notification.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when the notification ID is generated.
|
||||||
|
*/
|
||||||
|
protected getUniqueNotificationId(notificationId: number, component: string, siteId: string): Promise<number> {
|
||||||
|
if (!siteId || !component) {
|
||||||
|
return Promise.reject(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSiteCode(siteId).then((siteCode) => {
|
||||||
|
return this.getComponentCode(component).then((componentCode) => {
|
||||||
|
// We use the % operation to keep the number under Android's limit.
|
||||||
|
return (siteCode * 100000000 + componentCode * 10000000 + notificationId) % 2147483647;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an event triggered by the local notifications plugin.
|
||||||
|
*
|
||||||
|
* @param eventName Name of the event.
|
||||||
|
* @param notification Notification.
|
||||||
|
*/
|
||||||
|
protected handleEvent(eventName: string, notification: any): void {
|
||||||
|
if (notification && notification.data) {
|
||||||
|
this.logger.debug('Notification event: ' + eventName + '. Data:', notification.data);
|
||||||
|
|
||||||
|
this.notifyEvent(eventName, notification.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether local notifications plugin is installed.
|
||||||
|
*
|
||||||
|
* @return Whether local notifications plugin is installed.
|
||||||
|
*/
|
||||||
|
isAvailable(): boolean {
|
||||||
|
const win = <any> window;
|
||||||
|
|
||||||
|
return CoreApp.instance.isDesktop() || !!(win.cordova && win.cordova.plugins && win.cordova.plugins.notification &&
|
||||||
|
win.cordova.plugins.notification.local);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a notification has been triggered with the same trigger time.
|
||||||
|
*
|
||||||
|
* @param notification Notification to check.
|
||||||
|
* @param useQueue Whether to add the call to the queue.
|
||||||
|
* @return Promise resolved with a boolean indicating if promise is triggered (true) or not.
|
||||||
|
*/
|
||||||
|
async isTriggered(notification: ILocalNotification, useQueue: boolean = true): Promise<boolean> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = await this.appDB.getRecord(this.TRIGGERED_TABLE, { id: notification.id });
|
||||||
|
let triggered = (notification.trigger && notification.trigger.at) || 0;
|
||||||
|
|
||||||
|
if (typeof triggered != 'number') {
|
||||||
|
triggered = triggered.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored.at === triggered;
|
||||||
|
} catch (err) {
|
||||||
|
if (useQueue) {
|
||||||
|
const queueId = 'isTriggered-' + notification.id;
|
||||||
|
|
||||||
|
return this.queueRunner.run(queueId, () => LocalNotifications.instance.isTriggered(notification.id), {
|
||||||
|
allowRepeated: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return LocalNotifications.instance.isTriggered(notification.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify notification click to observers. Only the observers with the same component as the notification will be notified.
|
||||||
|
*
|
||||||
|
* @param data Data received by the notification.
|
||||||
|
*/
|
||||||
|
notifyClick(data: any): void {
|
||||||
|
this.notifyEvent('click', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify a certain event to observers. Only the observers with the same component as the notification will be notified.
|
||||||
|
*
|
||||||
|
* @param eventName Name of the event to notify.
|
||||||
|
* @param data Data received by the notification.
|
||||||
|
*/
|
||||||
|
notifyEvent(eventName: string, data: any): void {
|
||||||
|
// Execute the code in the Angular zone, so change detection doesn't stop working.
|
||||||
|
NgZone.instance.run(() => {
|
||||||
|
const component = data.component;
|
||||||
|
if (component) {
|
||||||
|
if (this.observables[eventName] && this.observables[eventName][component]) {
|
||||||
|
this.observables[eventName][component].next(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse some notification data.
|
||||||
|
*
|
||||||
|
* @param data Notification data.
|
||||||
|
* @return Parsed data.
|
||||||
|
*/
|
||||||
|
protected parseNotificationData(data: any): any {
|
||||||
|
if (!data) {
|
||||||
|
return {};
|
||||||
|
} else if (typeof data == 'string') {
|
||||||
|
return CoreTextUtils.instance.parseJSON(data, {});
|
||||||
|
} else {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the next request in queue.
|
||||||
|
*/
|
||||||
|
protected processNextRequest(): void {
|
||||||
|
const nextKey = Object.keys(this.codeRequestsQueue)[0];
|
||||||
|
let request,
|
||||||
|
promise;
|
||||||
|
|
||||||
|
if (typeof nextKey == 'undefined') {
|
||||||
|
// No more requests in queue, stop.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request = this.codeRequestsQueue[nextKey];
|
||||||
|
|
||||||
|
// Check if request is valid.
|
||||||
|
if (typeof request == 'object' && typeof request.table != 'undefined' && typeof request.id != 'undefined') {
|
||||||
|
// Get the code and resolve/reject all the promises of this request.
|
||||||
|
promise = this.getCode(request.table, request.id).then((code) => {
|
||||||
|
request.promises.forEach((p) => {
|
||||||
|
p.resolve(code);
|
||||||
|
});
|
||||||
|
}).catch((error) => {
|
||||||
|
request.promises.forEach((p) => {
|
||||||
|
p.reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
promise = Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once this item is treated, remove it and process next.
|
||||||
|
promise.finally(() => {
|
||||||
|
delete this.codeRequestsQueue[nextKey];
|
||||||
|
this.processNextRequest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an observer to be notified when a notification belonging to a certain component is clicked.
|
||||||
|
*
|
||||||
|
* @param component Component to listen notifications for.
|
||||||
|
* @param callback Function to call with the data received by the notification.
|
||||||
|
* @return Object with an "off" property to stop listening for clicks.
|
||||||
|
*/
|
||||||
|
registerClick(component: string, callback: Function): any {
|
||||||
|
return this.registerObserver('click', component, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an observer to be notified when a certain event is fired for a notification belonging to a certain component.
|
||||||
|
*
|
||||||
|
* @param eventName Name of the event to listen to.
|
||||||
|
* @param component Component to listen notifications for.
|
||||||
|
* @param callback Function to call with the data received by the notification.
|
||||||
|
* @return Object with an "off" property to stop listening for events.
|
||||||
|
*/
|
||||||
|
registerObserver(eventName: string, component: string, callback: Function): any {
|
||||||
|
this.logger.debug(`Register observer '${component}' for event '${eventName}'.`);
|
||||||
|
|
||||||
|
if (typeof this.observables[eventName] == 'undefined') {
|
||||||
|
this.observables[eventName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.observables[eventName][component] == 'undefined') {
|
||||||
|
// No observable for this component, create a new one.
|
||||||
|
this.observables[eventName][component] = new Subject<any>();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.observables[eventName][component].subscribe(callback);
|
||||||
|
|
||||||
|
return {
|
||||||
|
off: (): void => {
|
||||||
|
this.observables[eventName][component].unsubscribe(callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a notification from triggered store.
|
||||||
|
*
|
||||||
|
* @param id Notification ID.
|
||||||
|
* @return Promise resolved when it is removed.
|
||||||
|
*/
|
||||||
|
async removeTriggered(id: number): Promise<any> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
return this.appDB.deleteRecords(this.TRIGGERED_TABLE, { id: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a unique code. The request will be added to the queue and the queue is going to be started if it's paused.
|
||||||
|
*
|
||||||
|
* @param table Table to search in local DB.
|
||||||
|
* @param id ID of the element to get its code.
|
||||||
|
* @return Promise resolved when the code is retrieved.
|
||||||
|
*/
|
||||||
|
protected requestCode(table: string, id: string): Promise<number> {
|
||||||
|
const deferred = CoreUtils.instance.promiseDefer<number>(),
|
||||||
|
key = table + '#' + id,
|
||||||
|
isQueueEmpty = Object.keys(this.codeRequestsQueue).length == 0;
|
||||||
|
|
||||||
|
if (typeof this.codeRequestsQueue[key] != 'undefined') {
|
||||||
|
// There's already a pending request for this store and ID, add the promise to it.
|
||||||
|
this.codeRequestsQueue[key].promises.push(deferred);
|
||||||
|
} else {
|
||||||
|
// Add a pending request to the queue.
|
||||||
|
this.codeRequestsQueue[key] = {
|
||||||
|
table: table,
|
||||||
|
id: id,
|
||||||
|
promises: [deferred]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQueueEmpty) {
|
||||||
|
this.processNextRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
return deferred.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reschedule all notifications that are already scheduled.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when all notifications have been rescheduled.
|
||||||
|
*/
|
||||||
|
async rescheduleAll(): Promise<void> {
|
||||||
|
// Get all the scheduled notifications.
|
||||||
|
const notifications = await this.getAllScheduled();
|
||||||
|
|
||||||
|
await Promise.all(notifications.map(async (notification) => {
|
||||||
|
// Convert some properties to the needed types.
|
||||||
|
notification.data = this.parseNotificationData(notification.data);
|
||||||
|
|
||||||
|
const queueId = 'schedule-' + notification.id;
|
||||||
|
|
||||||
|
await this.queueRunner.run(queueId, () => this.scheduleNotification(notification), {
|
||||||
|
allowRepeated: true,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a local notification.
|
||||||
|
*
|
||||||
|
* @param notification Notification to schedule. Its ID should be lower than 10000000 and it should
|
||||||
|
* be unique inside its component and site.
|
||||||
|
* @param component Component triggering the notification. It is used to generate unique IDs.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @param alreadyUnique Whether the ID is already unique.
|
||||||
|
* @return Promise resolved when the notification is scheduled.
|
||||||
|
*/
|
||||||
|
async schedule(notification: ILocalNotification, component: string, siteId: string, alreadyUnique?: boolean): Promise<void> {
|
||||||
|
|
||||||
|
if (!alreadyUnique) {
|
||||||
|
notification.id = await this.getUniqueNotificationId(notification.id, component, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.data = notification.data || {};
|
||||||
|
notification.data.component = component;
|
||||||
|
notification.data.siteId = siteId;
|
||||||
|
|
||||||
|
if (CoreApp.instance.isAndroid()) {
|
||||||
|
notification.icon = notification.icon || 'res://icon';
|
||||||
|
notification.smallIcon = notification.smallIcon || 'res://smallicon';
|
||||||
|
notification.color = notification.color || CoreConfigConstants.notificoncolor;
|
||||||
|
|
||||||
|
const led: any = notification.led || {};
|
||||||
|
notification.led = {
|
||||||
|
color: led.color || 'FF9900',
|
||||||
|
on: led.on || 1000,
|
||||||
|
off: led.off || 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueId = 'schedule-' + notification.id;
|
||||||
|
|
||||||
|
await this.queueRunner.run(queueId, () => this.scheduleNotification(notification), {
|
||||||
|
allowRepeated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to schedule a notification object if it hasn't been triggered already.
|
||||||
|
*
|
||||||
|
* @param notification Notification to schedule.
|
||||||
|
* @return Promise resolved when scheduled.
|
||||||
|
*/
|
||||||
|
protected scheduleNotification(notification: ILocalNotification): Promise<void> {
|
||||||
|
// Check if the notification has been triggered already.
|
||||||
|
return this.isTriggered(notification, false).then((triggered) => {
|
||||||
|
// Cancel the current notification in case it gets scheduled twice.
|
||||||
|
return LocalNotifications.instance.cancel(notification.id).finally(() => {
|
||||||
|
if (!triggered) {
|
||||||
|
// Check if sound is enabled for notifications.
|
||||||
|
let promise;
|
||||||
|
|
||||||
|
if (this.canDisableSound()) {
|
||||||
|
promise = CoreConfig.instance.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true);
|
||||||
|
} else {
|
||||||
|
promise = Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise.then((soundEnabled) => {
|
||||||
|
if (!soundEnabled) {
|
||||||
|
notification.sound = null;
|
||||||
|
} else {
|
||||||
|
delete notification.sound; // Use default value.
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.foreground = true;
|
||||||
|
|
||||||
|
// Remove from triggered, since the notification could be in there with a different time.
|
||||||
|
this.removeTriggered(notification.id);
|
||||||
|
LocalNotifications.instance.schedule(notification);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to call when a notification is triggered. Stores the notification so it's not scheduled again unless the
|
||||||
|
* time is changed.
|
||||||
|
*
|
||||||
|
* @param notification Triggered notification.
|
||||||
|
* @return Promise resolved when stored, rejected otherwise.
|
||||||
|
*/
|
||||||
|
async trigger(notification: ILocalNotification): Promise<any> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id: notification.id,
|
||||||
|
at: notification.trigger && notification.trigger.at ? notification.trigger.at : Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.appDB.insertRecord(this.TRIGGERED_TABLE, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a component name.
|
||||||
|
*
|
||||||
|
* @param oldName The old name.
|
||||||
|
* @param newName The new name.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async updateComponentName(oldName: string, newName: string): Promise<any> {
|
||||||
|
await this.dbReady;
|
||||||
|
|
||||||
|
const oldId = this.COMPONENTS_TABLE + '#' + oldName,
|
||||||
|
newId = this.COMPONENTS_TABLE + '#' + newName;
|
||||||
|
|
||||||
|
return this.appDB.updateRecords(this.COMPONENTS_TABLE, {id: newId}, {id: oldId});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreLocalNotifications extends makeSingleton(CoreLocalNotificationsProvider) {}
|
|
@ -0,0 +1,394 @@
|
||||||
|
// (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 { FileEntry } from '@ionic-native/file';
|
||||||
|
|
||||||
|
import { CoreFilepool } from '@services/filepool';
|
||||||
|
import { CoreWSExternalFile } from '@services/ws';
|
||||||
|
import { CoreConstants } from '@core/constants';
|
||||||
|
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
|
||||||
|
import { makeSingleton } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate to register pluginfile information handlers.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CorePluginFileDelegate extends CoreDelegate {
|
||||||
|
protected handlerNameProperty = 'component';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('CorePluginFileDelegate', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React to a file being deleted.
|
||||||
|
*
|
||||||
|
* @param fileUrl The file URL used to download the file.
|
||||||
|
* @param path The path of the deleted file.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
fileDeleted(fileUrl: string, path: string, siteId?: string): Promise<any> {
|
||||||
|
const handler = this.getHandlerForFile({fileurl: fileUrl});
|
||||||
|
|
||||||
|
if (handler && handler.fileDeleted) {
|
||||||
|
return handler.fileDeleted(fileUrl, path, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a file can be downloaded. If so, return the file to download.
|
||||||
|
*
|
||||||
|
* @param file The file data.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with the file to use. Rejected if cannot download.
|
||||||
|
*/
|
||||||
|
getDownloadableFile(file: CoreWSExternalFile, siteId?: string): Promise<CoreWSExternalFile> {
|
||||||
|
const handler = this.getHandlerForFile(file);
|
||||||
|
|
||||||
|
return this.getHandlerDownloadableFile(file, handler, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a file can be downloaded. If so, return the file to download.
|
||||||
|
*
|
||||||
|
* @param file The file data.
|
||||||
|
* @param handler The handler to use.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with the file to use. Rejected if cannot download.
|
||||||
|
*/
|
||||||
|
protected async getHandlerDownloadableFile(file: CoreWSExternalFile, handler: CorePluginFileHandler, siteId?: string)
|
||||||
|
: Promise<CoreWSExternalFile> {
|
||||||
|
|
||||||
|
const isDownloadable = await this.isFileDownloadable(file, siteId);
|
||||||
|
|
||||||
|
if (!isDownloadable.downloadable) {
|
||||||
|
throw isDownloadable.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handler && handler.getDownloadableFile) {
|
||||||
|
const newFile = await handler.getDownloadableFile(file, siteId);
|
||||||
|
|
||||||
|
return newFile || file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the RegExp of the component and filearea described in the URL.
|
||||||
|
*
|
||||||
|
* @param args Arguments of the pluginfile URL defining component and filearea at least.
|
||||||
|
* @return RegExp to match the revision or undefined if not found.
|
||||||
|
*/
|
||||||
|
getComponentRevisionRegExp(args: string[]): RegExp {
|
||||||
|
// Get handler based on component (args[1]).
|
||||||
|
const handler = <CorePluginFileHandler> this.getHandler(args[1], true);
|
||||||
|
|
||||||
|
if (handler && handler.getComponentRevisionRegExp) {
|
||||||
|
return handler.getComponentRevisionRegExp(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by
|
||||||
|
* CoreFilepoolProvider.extractDownloadableFilesFromHtml.
|
||||||
|
*
|
||||||
|
* @param container Container where to get the URLs from.
|
||||||
|
* @return List of URLs.
|
||||||
|
*/
|
||||||
|
getDownloadableFilesFromHTML(container: HTMLElement): string[] {
|
||||||
|
let files = [];
|
||||||
|
|
||||||
|
for (const component in this.enabledHandlers) {
|
||||||
|
const handler = <CorePluginFileHandler> this.enabledHandlers[component];
|
||||||
|
|
||||||
|
if (handler && handler.getDownloadableFilesFromHTML) {
|
||||||
|
files = files.concat(handler.getDownloadableFilesFromHTML(container));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum the filesizes from a list if they are not downloaded.
|
||||||
|
*
|
||||||
|
* @param files List of files to sum its filesize.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with file size and a boolean to indicate if it is the total size or only partial.
|
||||||
|
*/
|
||||||
|
async getFilesDownloadSize(files: CoreWSExternalFile[], siteId?: string): Promise<{ size: number, total: boolean }> {
|
||||||
|
const filteredFiles = [];
|
||||||
|
|
||||||
|
await Promise.all(files.map(async (file) => {
|
||||||
|
const state = await CoreFilepool.instance.getFileStateByUrl(siteId, file.fileurl, file.timemodified);
|
||||||
|
|
||||||
|
if (state != CoreConstants.DOWNLOADED && state != CoreConstants.NOT_DOWNLOADABLE) {
|
||||||
|
filteredFiles.push(file);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return this.getFilesSize(filteredFiles, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum the filesizes from a list of files checking if the size will be partial or totally calculated.
|
||||||
|
*
|
||||||
|
* @param files List of files to sum its filesize.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with file size and a boolean to indicate if it is the total size or only partial.
|
||||||
|
*/
|
||||||
|
async getFilesSize(files: CoreWSExternalFile[], siteId?: string): Promise<{ size: number, total: boolean }> {
|
||||||
|
const result = {
|
||||||
|
size: 0,
|
||||||
|
total: true
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(files.map(async (file) => {
|
||||||
|
const size = await this.getFileSize(file, siteId);
|
||||||
|
|
||||||
|
if (typeof size == 'undefined') {
|
||||||
|
// We don't have the file size, cannot calculate its total size.
|
||||||
|
result.total = false;
|
||||||
|
} else {
|
||||||
|
result.size += size;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a file size.
|
||||||
|
*
|
||||||
|
* @param file The file data.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with the size.
|
||||||
|
*/
|
||||||
|
async getFileSize(file: CoreWSExternalFile, siteId?: string): Promise<number> {
|
||||||
|
const isDownloadable = await this.isFileDownloadable(file, siteId);
|
||||||
|
|
||||||
|
if (!isDownloadable.downloadable) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = this.getHandlerForFile(file);
|
||||||
|
|
||||||
|
// First of all check if file can be downloaded.
|
||||||
|
const downloadableFile = await this.getHandlerDownloadableFile(file, handler, siteId);
|
||||||
|
if (!downloadableFile) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handler && handler.getFileSize) {
|
||||||
|
try {
|
||||||
|
const size = handler.getFileSize(downloadableFile, siteId);
|
||||||
|
|
||||||
|
return size;
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadableFile.filesize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a handler to treat a certain file.
|
||||||
|
*
|
||||||
|
* @param file File data.
|
||||||
|
* @return Handler.
|
||||||
|
*/
|
||||||
|
protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler {
|
||||||
|
for (const component in this.enabledHandlers) {
|
||||||
|
const handler = <CorePluginFileHandler> this.enabledHandlers[component];
|
||||||
|
|
||||||
|
if (handler && handler.shouldHandleFile && handler.shouldHandleFile(file)) {
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is downloadable.
|
||||||
|
*
|
||||||
|
* @param file The file data.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise with the data.
|
||||||
|
*/
|
||||||
|
isFileDownloadable(file: CoreWSExternalFile, siteId?: string): Promise<CorePluginFileDownloadableResult> {
|
||||||
|
const handler = this.getHandlerForFile(file);
|
||||||
|
|
||||||
|
if (handler && handler.isFileDownloadable) {
|
||||||
|
return handler.isFileDownloadable(file, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to true.
|
||||||
|
return Promise.resolve({downloadable: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the revision number from a file URL.
|
||||||
|
*
|
||||||
|
* @param url URL to be replaced.
|
||||||
|
* @param args Arguments of the pluginfile URL defining component and filearea at least.
|
||||||
|
* @return Replaced URL without revision.
|
||||||
|
*/
|
||||||
|
removeRevisionFromUrl(url: string, args: string[]): string {
|
||||||
|
// Get handler based on component (args[1]).
|
||||||
|
const handler = <CorePluginFileHandler> this.getHandler(args[1], true);
|
||||||
|
|
||||||
|
if (handler && handler.getComponentRevisionRegExp && handler.getComponentRevisionReplace) {
|
||||||
|
const revisionRegex = handler.getComponentRevisionRegExp(args);
|
||||||
|
if (revisionRegex) {
|
||||||
|
return url.replace(revisionRegex, handler.getComponentRevisionReplace(args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treat a downloaded file.
|
||||||
|
*
|
||||||
|
* @param fileUrl The file URL used to download the file.
|
||||||
|
* @param file The file entry of the downloaded file.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
treatDownloadedFile(fileUrl: string, file: FileEntry, siteId?: string, onProgress?: (event: any) => any): Promise<any> {
|
||||||
|
const handler = this.getHandlerForFile({fileurl: fileUrl});
|
||||||
|
|
||||||
|
if (handler && handler.treatDownloadedFile) {
|
||||||
|
return handler.treatDownloadedFile(fileUrl, file, siteId, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CorePluginFile extends makeSingleton(CorePluginFileDelegate) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that all plugin file handlers must implement.
|
||||||
|
*/
|
||||||
|
export interface CorePluginFileHandler extends CoreDelegateHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "component" of the handler. It should match the "component" of pluginfile URLs.
|
||||||
|
* It is used to treat revision from URLs.
|
||||||
|
*/
|
||||||
|
component?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the RegExp to match the revision on pluginfile URLs.
|
||||||
|
*
|
||||||
|
* @param args Arguments of the pluginfile URL defining component and filearea at least.
|
||||||
|
* @return RegExp to match the revision on pluginfile URLs.
|
||||||
|
*/
|
||||||
|
getComponentRevisionRegExp?(args: string[]): RegExp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should return the string to remove the revision on pluginfile url.
|
||||||
|
*
|
||||||
|
* @param args Arguments of the pluginfile URL defining component and filearea at least.
|
||||||
|
* @return String to remove the revision on pluginfile url.
|
||||||
|
*/
|
||||||
|
getComponentRevisionReplace?(args: string[]): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React to a file being deleted.
|
||||||
|
*
|
||||||
|
* @param fileUrl The file URL used to download the file.
|
||||||
|
* @param path The path of the deleted file.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
fileDeleted?(fileUrl: string, path: string, siteId?: string): Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a file can be downloaded. If so, return the file to download.
|
||||||
|
*
|
||||||
|
* @param file The file data.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with the file to use. Rejected if cannot download.
|
||||||
|
*/
|
||||||
|
getDownloadableFile?(file: CoreWSExternalFile, siteId?: string): Promise<CoreWSExternalFile>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by
|
||||||
|
* CoreFilepoolProvider.extractDownloadableFilesFromHtml.
|
||||||
|
*
|
||||||
|
* @param container Container where to get the URLs from.
|
||||||
|
* @return List of URLs.
|
||||||
|
*/
|
||||||
|
getDownloadableFilesFromHTML?(container: HTMLElement): string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a file size.
|
||||||
|
*
|
||||||
|
* @param file The file data.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with the size.
|
||||||
|
*/
|
||||||
|
getFileSize?(file: CoreWSExternalFile, siteId?: string): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is downloadable.
|
||||||
|
*
|
||||||
|
* @param file The file data.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with a boolean and a reason why it isn't downloadable if needed.
|
||||||
|
*/
|
||||||
|
isFileDownloadable?(file: CoreWSExternalFile, siteId?: string): Promise<CorePluginFileDownloadableResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the file should be treated by this handler. It is used in functions where the component isn't used.
|
||||||
|
*
|
||||||
|
* @param file The file data.
|
||||||
|
* @return Whether the file should be treated by this handler.
|
||||||
|
*/
|
||||||
|
shouldHandleFile?(file: CoreWSExternalFile): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treat a downloaded file.
|
||||||
|
*
|
||||||
|
* @param fileUrl The file URL used to download the file.
|
||||||
|
* @param file The file entry of the downloaded file.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
treatDownloadedFile?(fileUrl: string, file: FileEntry, siteId?: string, onProgress?: (event: any) => any): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data about if a file is downloadable.
|
||||||
|
*/
|
||||||
|
export type CorePluginFileDownloadableResult = {
|
||||||
|
/**
|
||||||
|
* Whether it's downloadable.
|
||||||
|
*/
|
||||||
|
downloadable: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If not downloadable, the reason why it isn't.
|
||||||
|
*/
|
||||||
|
reason?: string;
|
||||||
|
};
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,211 @@
|
||||||
|
// (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 { CoreEvents, CoreEventsProvider } from '@services/events';
|
||||||
|
import { CoreSites, CoreSiteSchema } from '@services/sites';
|
||||||
|
import { makeSingleton } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Service that provides some features regarding synchronization.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreSyncProvider {
|
||||||
|
|
||||||
|
// Variables for the database.
|
||||||
|
protected SYNC_TABLE = 'sync';
|
||||||
|
protected siteSchema: CoreSiteSchema = {
|
||||||
|
name: 'CoreSyncProvider',
|
||||||
|
version: 1,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: this.SYNC_TABLE,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'component',
|
||||||
|
type: 'TEXT',
|
||||||
|
notNull: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'TEXT',
|
||||||
|
notNull: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'time',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'warnings',
|
||||||
|
type: 'TEXT'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
primaryKeys: ['component', 'id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store blocked sync objects.
|
||||||
|
protected blockedItems: { [siteId: string]: { [blockId: string]: { [operation: string]: boolean } } } = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
CoreSites.instance.registerSiteSchema(this.siteSchema);
|
||||||
|
|
||||||
|
// Unblock all blocks on logout.
|
||||||
|
CoreEvents.instance.on(CoreEventsProvider.LOGOUT, (data) => {
|
||||||
|
this.clearAllBlocks(data.siteId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block a component and ID so it cannot be synchronized.
|
||||||
|
*
|
||||||
|
* @param component Component name.
|
||||||
|
* @param id Unique ID per component.
|
||||||
|
* @param operation Operation name. If not defined, a default text is used.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
blockOperation(component: string, id: string | number, operation?: string, siteId?: string): void {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
const uniqueId = this.getUniqueSyncBlockId(component, id);
|
||||||
|
|
||||||
|
if (!this.blockedItems[siteId]) {
|
||||||
|
this.blockedItems[siteId] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.blockedItems[siteId][uniqueId]) {
|
||||||
|
this.blockedItems[siteId][uniqueId] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
operation = operation || '-';
|
||||||
|
|
||||||
|
this.blockedItems[siteId][uniqueId][operation] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all blocks for a site or all sites.
|
||||||
|
*
|
||||||
|
* @param siteId If set, clear the blocked objects only for this site. Otherwise clear them for all sites.
|
||||||
|
*/
|
||||||
|
clearAllBlocks(siteId?: string): void {
|
||||||
|
if (siteId) {
|
||||||
|
delete this.blockedItems[siteId];
|
||||||
|
} else {
|
||||||
|
this.blockedItems = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all blocks for a certain component.
|
||||||
|
*
|
||||||
|
* @param component Component name.
|
||||||
|
* @param id Unique ID per component.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
clearBlocks(component: string, id: string | number, siteId?: string): void {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
const uniqueId = this.getUniqueSyncBlockId(component, id);
|
||||||
|
if (this.blockedItems[siteId]) {
|
||||||
|
delete this.blockedItems[siteId][uniqueId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a sync record.
|
||||||
|
* @param component Component name.
|
||||||
|
* @param id Unique ID per component.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Record if found or reject.
|
||||||
|
*/
|
||||||
|
getSyncRecord(component: string, id: string | number, siteId?: string): Promise<any> {
|
||||||
|
return CoreSites.instance.getSiteDb(siteId).then((db) => {
|
||||||
|
return db.getRecord(this.SYNC_TABLE, { component: component, id: id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or Updates info of a sync record.
|
||||||
|
* @param component Component name.
|
||||||
|
* @param id Unique ID per component.
|
||||||
|
* @param data Data that updates the record.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with done.
|
||||||
|
*/
|
||||||
|
insertOrUpdateSyncRecord(component: string, id: string | number, data: any, siteId?: string): Promise<any> {
|
||||||
|
return CoreSites.instance.getSiteDb(siteId).then((db) => {
|
||||||
|
data.component = component;
|
||||||
|
data.id = id;
|
||||||
|
|
||||||
|
return db.insertRecord(this.SYNC_TABLE, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to create unique identifiers for a component and id.
|
||||||
|
*
|
||||||
|
* @param component Component name.
|
||||||
|
* @param id Unique ID per component.
|
||||||
|
* @return Unique sync id.
|
||||||
|
*/
|
||||||
|
protected getUniqueSyncBlockId(component: string, id: string | number): string {
|
||||||
|
return component + '#' + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a component is blocked.
|
||||||
|
* One block can have different operations. Here we check how many operations are being blocking the object.
|
||||||
|
*
|
||||||
|
* @param component Component name.
|
||||||
|
* @param id Unique ID per component.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether it's blocked.
|
||||||
|
*/
|
||||||
|
isBlocked(component: string, id: string | number, siteId?: string): boolean {
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
if (!this.blockedItems[siteId]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueId = this.getUniqueSyncBlockId(component, id);
|
||||||
|
if (!this.blockedItems[siteId][uniqueId]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(this.blockedItems[siteId][uniqueId]).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unblock an operation on a component and ID.
|
||||||
|
*
|
||||||
|
* @param component Component name.
|
||||||
|
* @param id Unique ID per component.
|
||||||
|
* @param operation Operation name. If not defined, a default text is used.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
*/
|
||||||
|
unblockOperation(component: string, id: string | number, operation?: string, siteId?: string): void {
|
||||||
|
operation = operation || '-';
|
||||||
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
|
||||||
|
const uniqueId = this.getUniqueSyncBlockId(component, id);
|
||||||
|
|
||||||
|
if (this.blockedItems[siteId] && this.blockedItems[siteId][uniqueId]) {
|
||||||
|
delete this.blockedItems[siteId][uniqueId][operation];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreSync extends makeSingleton(CoreSyncProvider) {}
|
|
@ -0,0 +1,68 @@
|
||||||
|
// (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 { CoreConfig } from '@services/config';
|
||||||
|
import { CoreInitHandler, CoreInitDelegate } from '@services/init';
|
||||||
|
import CoreConfigConstants from '@app/config.json';
|
||||||
|
import { makeSingleton } from '@singletons/core.singletons';
|
||||||
|
import { CoreLogger } from '@singletons/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory to handle app updates. This factory shouldn't be used outside of core.
|
||||||
|
*
|
||||||
|
* This service handles processes that need to be run when updating the app, like migrate Ionic 1 database data to Ionic 3.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreUpdateManagerProvider implements CoreInitHandler {
|
||||||
|
// Data for init delegate.
|
||||||
|
name = 'CoreUpdateManager';
|
||||||
|
priority = CoreInitDelegate.MAX_RECOMMENDED_PRIORITY + 300;
|
||||||
|
blocking = true;
|
||||||
|
|
||||||
|
protected VERSION_APPLIED = 'version_applied';
|
||||||
|
protected logger: CoreLogger;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger = CoreLogger.getInstance('CoreUpdateManagerProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app has been updated and performs the needed processes.
|
||||||
|
* This function shouldn't be used outside of core.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when the update process finishes.
|
||||||
|
*/
|
||||||
|
async load(): Promise<any> {
|
||||||
|
const promises = [];
|
||||||
|
const versionCode = CoreConfigConstants.versioncode;
|
||||||
|
|
||||||
|
const versionApplied: number = await CoreConfig.instance.get(this.VERSION_APPLIED, 0);
|
||||||
|
|
||||||
|
if (versionCode >= 3900 && versionApplied < 3900 && versionApplied > 0) {
|
||||||
|
// @todo: H5P update.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
await CoreConfig.instance.set(this.VERSION_APPLIED, versionCode);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error applying update from ${versionApplied} to ${versionCode}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreUpdateManager extends makeSingleton(CoreUpdateManagerProvider) {}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,450 @@
|
||||||
|
// (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 { WKUserScriptWindow, WKUserScriptInjectionTime } from 'cordova-plugin-wkuserscript';
|
||||||
|
|
||||||
|
import { CoreApp, CoreAppProvider } from '@services/app';
|
||||||
|
import { CoreFile } from '@services/file';
|
||||||
|
import { CoreFileHelper } from '@services/file-helper';
|
||||||
|
import { CoreSites } from '@services/sites';
|
||||||
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
|
import { CoreUrlUtils } from '@services/utils/url';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
|
||||||
|
import { makeSingleton, Translate, Network, Platform, NgZone } from '@singletons/core.singletons';
|
||||||
|
import { CoreLogger } from '@singletons/logger';
|
||||||
|
import { CoreUrl } from '@singletons/url';
|
||||||
|
import { CoreWindow } from '@singletons/window';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Utils" service with helper functions for iframes, embed and similar.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreIframeUtilsProvider {
|
||||||
|
static FRAME_TAGS = ['iframe', 'frame', 'object', 'embed'];
|
||||||
|
|
||||||
|
protected logger: CoreLogger;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger = CoreLogger.getInstance('CoreUtilsProvider');
|
||||||
|
|
||||||
|
const win = <WKUserScriptWindow> window;
|
||||||
|
|
||||||
|
if (CoreApp.instance.isIOS() && win.WKUserScript) {
|
||||||
|
Platform.instance.ready().then(() => {
|
||||||
|
// Inject code to the iframes because we cannot access the online ones.
|
||||||
|
const wwwPath = CoreFile.instance.getWWWAbsolutePath();
|
||||||
|
const linksPath = CoreTextUtils.instance.concatenatePaths(wwwPath, 'assets/js/iframe-treat-links.js');
|
||||||
|
const recaptchaPath = CoreTextUtils.instance.concatenatePaths(wwwPath, 'assets/js/iframe-recaptcha.js');
|
||||||
|
|
||||||
|
win.WKUserScript.addScript({id: 'CoreIframeUtilsLinksScript', file: linksPath});
|
||||||
|
win.WKUserScript.addScript({
|
||||||
|
id: 'CoreIframeUtilsRecaptchaScript',
|
||||||
|
file: recaptchaPath,
|
||||||
|
injectionTime: WKUserScriptInjectionTime.END,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle post messages received by iframes.
|
||||||
|
window.addEventListener('message', this.handleIframeMessage.bind(this));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a frame uses an online URL but the app is offline. If it does, the iframe is hidden and a warning is shown.
|
||||||
|
*
|
||||||
|
* @param element The frame to check (iframe, embed, ...).
|
||||||
|
* @param isSubframe Whether it's a frame inside another frame.
|
||||||
|
* @return True if frame is online and the app is offline, false otherwise.
|
||||||
|
*/
|
||||||
|
checkOnlineFrameInOffline(element: any, isSubframe?: boolean): boolean {
|
||||||
|
const src = element.src || element.data;
|
||||||
|
|
||||||
|
if (src && src != 'about:blank' && !CoreUrlUtils.instance.isLocalFileUrl(src) && !CoreApp.instance.isOnline()) {
|
||||||
|
if (element.classList.contains('core-iframe-offline-disabled')) {
|
||||||
|
// Iframe already hidden, stop.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frame has an online URL but the app is offline. Show a warning, or a link if the URL can be opened in the app.
|
||||||
|
const div = document.createElement('div');
|
||||||
|
|
||||||
|
div.setAttribute('text-center', '');
|
||||||
|
div.setAttribute('padding', '');
|
||||||
|
div.classList.add('core-iframe-offline-warning');
|
||||||
|
|
||||||
|
const site = CoreSites.instance.getCurrentSite();
|
||||||
|
const username = site ? site.getInfo().username : undefined;
|
||||||
|
// @todo Handle link
|
||||||
|
|
||||||
|
// Add a class to specify that the iframe is hidden.
|
||||||
|
element.classList.add('core-iframe-offline-disabled');
|
||||||
|
|
||||||
|
if (isSubframe) {
|
||||||
|
// We cannot apply CSS styles in subframes, just hide the iframe.
|
||||||
|
element.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the network changes, check it again.
|
||||||
|
const subscription = Network.instance.onConnect().subscribe(() => {
|
||||||
|
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||||
|
NgZone.instance.run(() => {
|
||||||
|
if (!this.checkOnlineFrameInOffline(element, isSubframe)) {
|
||||||
|
// Now the app is online, no need to check connection again.
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else if (element.classList.contains('core-iframe-offline-disabled')) {
|
||||||
|
// Reload the frame.
|
||||||
|
element.src = element.src;
|
||||||
|
element.data = element.data;
|
||||||
|
|
||||||
|
// Remove the warning and show the iframe
|
||||||
|
CoreDomUtils.instance.removeElement(element.parentElement, 'div.core-iframe-offline-warning');
|
||||||
|
element.classList.remove('core-iframe-offline-disabled');
|
||||||
|
|
||||||
|
if (isSubframe) {
|
||||||
|
element.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an element, return the content window and document.
|
||||||
|
* Please notice that the element should be an iframe, embed or similar.
|
||||||
|
*
|
||||||
|
* @param element Element to treat (iframe, embed, ...).
|
||||||
|
* @return Window and Document.
|
||||||
|
*/
|
||||||
|
getContentWindowAndDocument(element: any): { window: Window, document: Document } {
|
||||||
|
let contentWindow: Window = element.contentWindow;
|
||||||
|
let contentDocument: Document;
|
||||||
|
|
||||||
|
try {
|
||||||
|
contentDocument = element.contentDocument || (contentWindow && contentWindow.document);
|
||||||
|
} catch (ex) {
|
||||||
|
// Ignore errors.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentWindow && contentDocument) {
|
||||||
|
// It's probably an <object>. Try to get the window.
|
||||||
|
contentWindow = contentDocument.defaultView;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentWindow && element.getSVGDocument) {
|
||||||
|
// It's probably an <embed>. Try to get the window and the document.
|
||||||
|
try {
|
||||||
|
contentDocument = element.getSVGDocument();
|
||||||
|
} catch (ex) {
|
||||||
|
// Ignore errors.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentDocument && contentDocument.defaultView) {
|
||||||
|
contentWindow = contentDocument.defaultView;
|
||||||
|
} else if (element.window) {
|
||||||
|
contentWindow = element.window;
|
||||||
|
} else if (element.getWindow) {
|
||||||
|
contentWindow = element.getWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { window: contentWindow, document: contentDocument };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle some iframe messages.
|
||||||
|
*
|
||||||
|
* @param event Message event.
|
||||||
|
*/
|
||||||
|
handleIframeMessage(event: MessageEvent): void {
|
||||||
|
if (!event.data || event.data.environment != 'moodleapp' || event.data.context != 'iframe') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.data.action) {
|
||||||
|
case 'window_open':
|
||||||
|
this.windowOpen(event.data.url, event.data.name);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'link_clicked':
|
||||||
|
this.linkClicked(event.data.link);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redefine the open method in the contentWindow of an element and the sub frames.
|
||||||
|
* Please notice that the element should be an iframe, embed or similar.
|
||||||
|
*
|
||||||
|
* @param element Element to treat (iframe, embed, ...).
|
||||||
|
* @param contentWindow The window of the element contents.
|
||||||
|
* @param contentDocument The document of the element contents.
|
||||||
|
* @param navCtrl NavController to use if a link can be opened in the app.
|
||||||
|
*/
|
||||||
|
redefineWindowOpen(element: any, contentWindow: Window, contentDocument: Document, navCtrl?: any): void {
|
||||||
|
if (contentWindow) {
|
||||||
|
// Intercept window.open.
|
||||||
|
(<any> contentWindow).open = (url: string, name: string): Window => {
|
||||||
|
this.windowOpen(url, name, element, navCtrl);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentDocument) {
|
||||||
|
// Search sub frames.
|
||||||
|
CoreIframeUtilsProvider.FRAME_TAGS.forEach((tag) => {
|
||||||
|
const elements = Array.from(contentDocument.querySelectorAll(tag));
|
||||||
|
elements.forEach((subElement) => {
|
||||||
|
this.treatFrame(subElement, true, navCtrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept window.open in a frame and its subframes, shows an error modal instead.
|
||||||
|
* Search links (<a>) and open them in browser or InAppBrowser if needed.
|
||||||
|
*
|
||||||
|
* @param element Element to treat (iframe, embed, ...).
|
||||||
|
* @param isSubframe Whether it's a frame inside another frame.
|
||||||
|
* @param navCtrl NavController to use if a link can be opened in the app.
|
||||||
|
*/
|
||||||
|
treatFrame(element: any, isSubframe?: boolean, navCtrl?: any): void {
|
||||||
|
if (element) {
|
||||||
|
this.checkOnlineFrameInOffline(element, isSubframe);
|
||||||
|
|
||||||
|
let winAndDoc = this.getContentWindowAndDocument(element);
|
||||||
|
// Redefine window.open in this element and sub frames, it might have been loaded already.
|
||||||
|
this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document, navCtrl);
|
||||||
|
// Treat links.
|
||||||
|
this.treatFrameLinks(element, winAndDoc.document);
|
||||||
|
|
||||||
|
element.addEventListener('load', () => {
|
||||||
|
this.checkOnlineFrameInOffline(element, isSubframe);
|
||||||
|
|
||||||
|
// Element loaded, redefine window.open and treat links again.
|
||||||
|
winAndDoc = this.getContentWindowAndDocument(element);
|
||||||
|
this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document, navCtrl);
|
||||||
|
this.treatFrameLinks(element, winAndDoc.document);
|
||||||
|
|
||||||
|
if (winAndDoc.window) {
|
||||||
|
// Send a resize events to the iframe so it calculates the right size if needed.
|
||||||
|
setTimeout(() => {
|
||||||
|
winAndDoc.window.dispatchEvent(new Event('resize'));
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search links (<a>) in a frame and open them in browser or InAppBrowser if needed.
|
||||||
|
* Only links that haven't been treated by the frame's Javascript will be treated.
|
||||||
|
*
|
||||||
|
* @param element Element to treat (iframe, embed, ...).
|
||||||
|
* @param contentDocument The document of the element contents.
|
||||||
|
*/
|
||||||
|
treatFrameLinks(element: any, contentDocument: Document): void {
|
||||||
|
if (!contentDocument) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentDocument.addEventListener('click', (event) => {
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
// Event already prevented by some other code.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the link being clicked.
|
||||||
|
let el = <Element> event.target;
|
||||||
|
while (el && el.tagName !== 'A') {
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = <CoreIframeHTMLAnchorElement> el;
|
||||||
|
if (!link || link.treated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click listener to the link, this way if the iframe has added a listener to the link it will be executed first.
|
||||||
|
link.treated = true;
|
||||||
|
link.addEventListener('click', this.linkClicked.bind(this, link, element));
|
||||||
|
}, {
|
||||||
|
capture: true // Use capture to fix this listener not called if the element clicked is too deep in the DOM.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a window.open called by a frame.
|
||||||
|
*
|
||||||
|
* @param url URL passed to window.open.
|
||||||
|
* @param name Name passed to window.open.
|
||||||
|
* @param element HTML element of the frame.
|
||||||
|
* @param navCtrl NavController to use if a link can be opened in the app.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async windowOpen(url: string, name: string, element?: any, navCtrl?: any): Promise<void> {
|
||||||
|
const scheme = CoreUrlUtils.instance.getUrlScheme(url);
|
||||||
|
if (!scheme) {
|
||||||
|
// It's a relative URL, use the frame src to create the full URL.
|
||||||
|
const src = element && (element.src || element.data);
|
||||||
|
if (src) {
|
||||||
|
const dirAndFile = CoreFile.instance.getFileAndDirectoryFromPath(src);
|
||||||
|
if (dirAndFile.directory) {
|
||||||
|
url = CoreTextUtils.instance.concatenatePaths(dirAndFile.directory, url);
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Cannot get iframe dir path to open relative url', url, element);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Cannot get iframe src to open relative url', url, element);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name == '_self') {
|
||||||
|
// Link should be loaded in the same frame.
|
||||||
|
if (!element) {
|
||||||
|
this.logger.warn('Cannot load URL in iframe because the element was not supplied', url);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.tagName.toLowerCase() == 'object') {
|
||||||
|
element.setAttribute('data', url);
|
||||||
|
} else {
|
||||||
|
element.setAttribute('src', url);
|
||||||
|
}
|
||||||
|
} else if (CoreUrlUtils.instance.isLocalFileUrl(url)) {
|
||||||
|
// It's a local file.
|
||||||
|
const filename = url.substr(url.lastIndexOf('/') + 1);
|
||||||
|
|
||||||
|
if (!CoreFileHelper.instance.isOpenableInApp({ filename })) {
|
||||||
|
try {
|
||||||
|
await CoreFileHelper.instance.showConfirmOpenUnsupportedFile();
|
||||||
|
} catch (error) {
|
||||||
|
return; // Cancelled, stop.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await CoreUtils.instance.openFile(url);
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.instance.showErrorModal(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// It's an external link, check if it can be opened in the app.
|
||||||
|
await CoreWindow.open(url, name, {
|
||||||
|
navCtrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link inside a frame was clicked.
|
||||||
|
*
|
||||||
|
* @param link Data of the link clicked.
|
||||||
|
* @param element Frame element.
|
||||||
|
* @param event Click event.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async linkClicked(link: {href: string, target?: string}, element?: HTMLFrameElement | HTMLObjectElement,
|
||||||
|
event?: Event): Promise<void> {
|
||||||
|
if (event && event.defaultPrevented) {
|
||||||
|
// Event already prevented by some other code.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParts = CoreUrl.parse(link.href);
|
||||||
|
if (!link.href || (urlParts.protocol && urlParts.protocol == 'javascript')) {
|
||||||
|
// Links with no URL and Javascript links are ignored.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CoreUrlUtils.instance.isLocalFileUrlScheme(urlParts.protocol, urlParts.domain)) {
|
||||||
|
// Scheme suggests it's an external resource.
|
||||||
|
event && event.preventDefault();
|
||||||
|
|
||||||
|
const frameSrc = element && ((<HTMLFrameElement> element).src || (<HTMLObjectElement> element).data);
|
||||||
|
|
||||||
|
// If the frame is not local, check the target to identify how to treat the link.
|
||||||
|
if (element && !CoreUrlUtils.instance.isLocalFileUrl(frameSrc) && (!link.target || link.target == '_self')) {
|
||||||
|
// Load the link inside the frame itself.
|
||||||
|
if (element.tagName.toLowerCase() == 'object') {
|
||||||
|
element.setAttribute('data', link.href);
|
||||||
|
} else {
|
||||||
|
element.setAttribute('src', link.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frame is local or the link needs to be opened in a new window. Open in browser.
|
||||||
|
if (!CoreSites.instance.isLoggedIn()) {
|
||||||
|
CoreUtils.instance.openInBrowser(link.href);
|
||||||
|
} else {
|
||||||
|
await CoreSites.instance.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(link.href);
|
||||||
|
}
|
||||||
|
} else if (link.target == '_parent' || link.target == '_top' || link.target == '_blank') {
|
||||||
|
// Opening links with _parent, _top or _blank can break the app. We'll open it in InAppBrowser.
|
||||||
|
event && event.preventDefault();
|
||||||
|
|
||||||
|
const filename = link.href.substr(link.href.lastIndexOf('/') + 1);
|
||||||
|
|
||||||
|
if (!CoreFileHelper.instance.isOpenableInApp({ filename })) {
|
||||||
|
try {
|
||||||
|
await CoreFileHelper.instance.showConfirmOpenUnsupportedFile();
|
||||||
|
} catch (error) {
|
||||||
|
return; // Cancelled, stop.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await CoreUtils.instance.openFile(link.href);
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.instance.showErrorModal(error);
|
||||||
|
}
|
||||||
|
} else if (CoreApp.instance.isIOS() && (!link.target || link.target == '_self') && element) {
|
||||||
|
// In cordova ios 4.1.0 links inside iframes stopped working. We'll manually treat them.
|
||||||
|
event && event.preventDefault();
|
||||||
|
if (element.tagName.toLowerCase() == 'object') {
|
||||||
|
element.setAttribute('data', link.href);
|
||||||
|
} else {
|
||||||
|
element.setAttribute('src', link.href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreIframeUtils extends makeSingleton(CoreIframeUtilsProvider) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtype of HTMLAnchorElement, with some calculated data.
|
||||||
|
*/
|
||||||
|
type CoreIframeHTMLAnchorElement = HTMLAnchorElement & {
|
||||||
|
treated?: boolean; // Whether the element has been treated already.
|
||||||
|
};
|
|
@ -0,0 +1,553 @@
|
||||||
|
// (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 { CoreFile } from '@services/file';
|
||||||
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
|
import { makeSingleton, Translate, Http } from '@singletons/core.singletons';
|
||||||
|
import { CoreLogger } from '@singletons/logger';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Utils" service with helper functions for mimetypes and extensions.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreMimetypeUtilsProvider {
|
||||||
|
protected logger: CoreLogger;
|
||||||
|
protected extToMime = {}; // Object to map extensions -> mimetypes.
|
||||||
|
protected mimeToExt = {}; // Object to map mimetypes -> extensions.
|
||||||
|
protected groupsMimeInfo = {}; // Object to hold extensions and mimetypes that belong to a certain "group" (audio, video, ...).
|
||||||
|
protected extensionRegex = /^[a-z0-9]+$/;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger = CoreLogger.getInstance('CoreMimetypeUtilsProvider');
|
||||||
|
|
||||||
|
Http.instance.get('assets/exttomime.json').subscribe((result) => {
|
||||||
|
this.extToMime = result;
|
||||||
|
}, (err) => {
|
||||||
|
// Error, shouldn't happen.
|
||||||
|
});
|
||||||
|
|
||||||
|
Http.instance.get('assets/mimetoext.json').subscribe((result) => {
|
||||||
|
this.mimeToExt = result;
|
||||||
|
}, (err) => {
|
||||||
|
// Error, shouldn't happen.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file extension can be embedded without using iframes.
|
||||||
|
*
|
||||||
|
* @param extension Extension.
|
||||||
|
* @return Whether it can be embedded.
|
||||||
|
*/
|
||||||
|
canBeEmbedded(extension: string): boolean {
|
||||||
|
return this.isExtensionInGroup(extension, ['web_image', 'web_video', 'web_audio']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean a extension, removing the dot, hash, extra params...
|
||||||
|
*
|
||||||
|
* @param extension Extension to clean.
|
||||||
|
* @return Clean extension.
|
||||||
|
*/
|
||||||
|
cleanExtension(extension: string): string {
|
||||||
|
if (!extension) {
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the extension has parameters, remove them.
|
||||||
|
let position = extension.indexOf('?');
|
||||||
|
if (position > -1) {
|
||||||
|
extension = extension.substr(0, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the extension has an anchor, remove it.
|
||||||
|
position = extension.indexOf('#');
|
||||||
|
if (position > -1) {
|
||||||
|
extension = extension.substr(0, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove hash in extension if there's any (added by filepool).
|
||||||
|
extension = extension.replace(/_.{32}$/, '');
|
||||||
|
|
||||||
|
// Remove dot from the extension if found.
|
||||||
|
if (extension && extension[0] == '.') {
|
||||||
|
extension = extension.substr(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the mimetypes and extensions info for a certain group.
|
||||||
|
*
|
||||||
|
* @param group Group name.
|
||||||
|
*/
|
||||||
|
protected fillGroupMimeInfo(group: string): void {
|
||||||
|
const mimetypes = {}; // Use an object to prevent duplicates.
|
||||||
|
const extensions = []; // Extensions are unique.
|
||||||
|
|
||||||
|
for (const extension in this.extToMime) {
|
||||||
|
const data = this.extToMime[extension];
|
||||||
|
if (data.type && data.groups && data.groups.indexOf(group) != -1) {
|
||||||
|
// This extension has the group, add it to the list.
|
||||||
|
mimetypes[data.type] = true;
|
||||||
|
extensions.push(extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.groupsMimeInfo[group] = {
|
||||||
|
mimetypes: Object.keys(mimetypes),
|
||||||
|
extensions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the extension of a mimetype. Returns undefined if not found.
|
||||||
|
*
|
||||||
|
* @param mimetype Mimetype.
|
||||||
|
* @param url URL of the file. It will be used if there's more than one possible extension.
|
||||||
|
* @return Extension.
|
||||||
|
*/
|
||||||
|
getExtension(mimetype: string, url?: string): string {
|
||||||
|
mimetype = mimetype || '';
|
||||||
|
mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any.
|
||||||
|
|
||||||
|
if (mimetype == 'application/x-forcedownload' || mimetype == 'application/forcedownload') {
|
||||||
|
// Couldn't get the right mimetype, try to guess it.
|
||||||
|
return this.guessExtensionFromUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensions = this.mimeToExt[mimetype];
|
||||||
|
if (extensions && extensions.length) {
|
||||||
|
if (extensions.length > 1 && url) {
|
||||||
|
// There's more than one possible extension. Check if the URL has extension.
|
||||||
|
const candidate = this.guessExtensionFromUrl(url);
|
||||||
|
if (extensions.indexOf(candidate) != -1) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the embed type to display an embedded file and mimetype if not found.
|
||||||
|
*
|
||||||
|
* @param file File object.
|
||||||
|
* @paran path Alternative path that will override fileurl from file object.
|
||||||
|
*/
|
||||||
|
getEmbeddedHtml(file: any, path?: string): string {
|
||||||
|
let ext;
|
||||||
|
const filename = file.filename || file.name;
|
||||||
|
|
||||||
|
if (file.mimetype) {
|
||||||
|
ext = this.getExtension(file.mimetype);
|
||||||
|
} else {
|
||||||
|
ext = this.getFileExtension(filename);
|
||||||
|
file.mimetype = this.getMimeType(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.canBeEmbedded(ext)) {
|
||||||
|
file.embedType = this.getExtensionType(ext);
|
||||||
|
|
||||||
|
path = CoreFile.instance.convertFileSrc(path || file.fileurl || file.url || (file.toURL && file.toURL()));
|
||||||
|
|
||||||
|
if (file.embedType == 'image') {
|
||||||
|
return '<img src="' + path + '">';
|
||||||
|
}
|
||||||
|
if (file.embedType == 'audio' || file.embedType == 'video') {
|
||||||
|
return '<' + file.embedType + ' controls title="' + filename + '" src="' + path + '">' +
|
||||||
|
'<source src="' + path + '" type="' + file.mimetype + '">' +
|
||||||
|
'</' + file.embedType + '>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL of the icon of an extension.
|
||||||
|
*
|
||||||
|
* @param extension Extension.
|
||||||
|
* @return Icon URL.
|
||||||
|
*/
|
||||||
|
getExtensionIcon(extension: string): string {
|
||||||
|
const icon = this.getExtensionIconName(extension) || 'unknown';
|
||||||
|
|
||||||
|
return this.getFileIconForType(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the name of the icon of an extension.
|
||||||
|
*
|
||||||
|
* @param extension Extension.
|
||||||
|
* @return Icon. Undefined if not found.
|
||||||
|
*/
|
||||||
|
getExtensionIconName(extension: string): string {
|
||||||
|
if (this.extToMime[extension]) {
|
||||||
|
if (this.extToMime[extension].icon) {
|
||||||
|
return this.extToMime[extension].icon;
|
||||||
|
} else {
|
||||||
|
const type = this.extToMime[extension].type.split('/')[0];
|
||||||
|
if (type == 'video' || type == 'text' || type == 'image' || type == 'document' || type == 'audio') {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the "type" (string) of an extension, something like "image", "video" or "audio".
|
||||||
|
*
|
||||||
|
* @param extension Extension.
|
||||||
|
* @return Type of the extension.
|
||||||
|
*/
|
||||||
|
getExtensionType(extension: string): string {
|
||||||
|
extension = this.cleanExtension(extension);
|
||||||
|
|
||||||
|
if (this.extToMime[extension] && this.extToMime[extension].string) {
|
||||||
|
return this.extToMime[extension].string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the possible extensions of a mimetype. Returns empty array if not found.
|
||||||
|
*
|
||||||
|
* @param mimetype Mimetype.
|
||||||
|
* @return Extensions.
|
||||||
|
*/
|
||||||
|
getExtensions(mimetype: string): string[] {
|
||||||
|
mimetype = mimetype || '';
|
||||||
|
mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any.
|
||||||
|
|
||||||
|
return this.mimeToExt[mimetype] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a file icon URL based on its file name.
|
||||||
|
*
|
||||||
|
* @param The name of the file.
|
||||||
|
* @return The path to a file icon.
|
||||||
|
*/
|
||||||
|
getFileIcon(filename: string): string {
|
||||||
|
const ext = this.getFileExtension(filename);
|
||||||
|
const icon = this.getExtensionIconName(ext) || 'unknown';
|
||||||
|
|
||||||
|
return this.getFileIconForType(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the folder icon URL.
|
||||||
|
*
|
||||||
|
* @return The path to a folder icon.
|
||||||
|
*/
|
||||||
|
getFolderIcon(): string {
|
||||||
|
return 'assets/img/files/folder-64.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a type (audio, video, html, ...), return its file icon path.
|
||||||
|
*
|
||||||
|
* @param type The type to get the icon.
|
||||||
|
* @return The icon path.
|
||||||
|
*/
|
||||||
|
getFileIconForType(type: string): string {
|
||||||
|
return 'assets/img/files/' + type + '-64.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guess the extension of a file from its URL.
|
||||||
|
* This is very weak and unreliable.
|
||||||
|
*
|
||||||
|
* @param fileUrl The file URL.
|
||||||
|
* @return The lowercased extension without the dot, or undefined.
|
||||||
|
*/
|
||||||
|
guessExtensionFromUrl(fileUrl: string): string {
|
||||||
|
const split = fileUrl.split('.');
|
||||||
|
let candidate;
|
||||||
|
let extension;
|
||||||
|
let position;
|
||||||
|
|
||||||
|
if (split.length > 1) {
|
||||||
|
candidate = split.pop().toLowerCase();
|
||||||
|
// Remove params if any.
|
||||||
|
position = candidate.indexOf('?');
|
||||||
|
if (position > -1) {
|
||||||
|
candidate = candidate.substr(0, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.extensionRegex.test(candidate)) {
|
||||||
|
extension = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check extension corresponds to a mimetype to know if it's valid.
|
||||||
|
if (extension && typeof this.getMimeType(extension) == 'undefined') {
|
||||||
|
this.logger.warn('Guess file extension: Not valid extension ' + extension);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the file extension of a file.
|
||||||
|
* When the file does not have an extension, it returns undefined.
|
||||||
|
*
|
||||||
|
* @param filename The file name.
|
||||||
|
* @return The lowercased extension, or undefined.
|
||||||
|
*/
|
||||||
|
getFileExtension(filename: string): string {
|
||||||
|
const dot = filename.lastIndexOf('.');
|
||||||
|
let ext;
|
||||||
|
|
||||||
|
if (dot > -1) {
|
||||||
|
ext = filename.substr(dot + 1).toLowerCase();
|
||||||
|
ext = this.cleanExtension(ext);
|
||||||
|
|
||||||
|
// Check extension corresponds to a mimetype to know if it's valid.
|
||||||
|
if (typeof this.getMimeType(ext) == 'undefined') {
|
||||||
|
this.logger.warn('Get file extension: Not valid extension ' + ext);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mimetype/extension info belonging to a certain group.
|
||||||
|
*
|
||||||
|
* @param group Group name.
|
||||||
|
* @param field The field to get. If not supplied, all the info will be returned.
|
||||||
|
* @return Info for the group.
|
||||||
|
*/
|
||||||
|
getGroupMimeInfo(group: string, field?: string): any {
|
||||||
|
if (typeof this.groupsMimeInfo[group] == 'undefined') {
|
||||||
|
this.fillGroupMimeInfo(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
return this.groupsMimeInfo[group][field];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.groupsMimeInfo[group];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mimetype of an extension. Returns undefined if not found.
|
||||||
|
*
|
||||||
|
* @param extension Extension.
|
||||||
|
* @return Mimetype.
|
||||||
|
*/
|
||||||
|
getMimeType(extension: string): string {
|
||||||
|
extension = this.cleanExtension(extension);
|
||||||
|
|
||||||
|
if (this.extToMime[extension] && this.extToMime[extension].type) {
|
||||||
|
return this.extToMime[extension].type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains descriptions for file types (e.g. 'Microsoft Word document') from the language file.
|
||||||
|
* Based on Moodle's get_mimetype_description.
|
||||||
|
*
|
||||||
|
* @param obj Instance of FileEntry OR object with 'filename' and 'mimetype' OR string with mimetype.
|
||||||
|
* @param capitalise If true, capitalises first character of result.
|
||||||
|
* @return Type description.
|
||||||
|
*/
|
||||||
|
getMimetypeDescription(obj: any, capitalise?: boolean): string {
|
||||||
|
const langPrefix = 'assets.mimetypes.';
|
||||||
|
let filename = '';
|
||||||
|
let mimetype = '';
|
||||||
|
let extension = '';
|
||||||
|
|
||||||
|
if (typeof obj == 'object' && typeof obj.file == 'function') {
|
||||||
|
// It's a FileEntry. Don't use the file function because it's asynchronous and the type isn't reliable.
|
||||||
|
filename = obj.name;
|
||||||
|
} else if (typeof obj == 'object') {
|
||||||
|
filename = obj.filename || '';
|
||||||
|
mimetype = obj.mimetype || '';
|
||||||
|
} else {
|
||||||
|
mimetype = obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename) {
|
||||||
|
extension = this.getFileExtension(filename);
|
||||||
|
|
||||||
|
if (!mimetype) {
|
||||||
|
// Try to calculate the mimetype using the extension.
|
||||||
|
mimetype = this.getMimeType(extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mimetype) {
|
||||||
|
// Don't have the mimetype, stop.
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extension) {
|
||||||
|
extension = this.getExtension(mimetype);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimetypeStr = this.getMimetypeType(mimetype) || '';
|
||||||
|
const chunks = mimetype.split('/');
|
||||||
|
const attr = {
|
||||||
|
mimetype,
|
||||||
|
ext: extension || '',
|
||||||
|
mimetype1: chunks[0],
|
||||||
|
mimetype2: chunks[1] || '',
|
||||||
|
};
|
||||||
|
const translateParams = {};
|
||||||
|
|
||||||
|
for (const key in attr) {
|
||||||
|
const value = attr[key];
|
||||||
|
translateParams[key] = value;
|
||||||
|
translateParams[key.toUpperCase()] = value.toUpperCase();
|
||||||
|
translateParams[CoreTextUtils.instance.ucFirst(key)] = CoreTextUtils.instance.ucFirst(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME types may include + symbol but this is not permitted in string ids.
|
||||||
|
const safeMimetype = mimetype.replace(/\+/g, '_');
|
||||||
|
const safeMimetypeStr = mimetypeStr.replace(/\+/g, '_');
|
||||||
|
const safeMimetypeTrns = Translate.instance.instant(langPrefix + safeMimetype, { $a: translateParams });
|
||||||
|
const safeMimetypeStrTrns = Translate.instance.instant(langPrefix + safeMimetypeStr, { $a: translateParams });
|
||||||
|
const defaultTrns = Translate.instance.instant(langPrefix + 'default', { $a: translateParams });
|
||||||
|
let result = mimetype;
|
||||||
|
|
||||||
|
if (safeMimetypeTrns != langPrefix + safeMimetype) {
|
||||||
|
result = safeMimetypeTrns;
|
||||||
|
} else if (safeMimetypeStrTrns != langPrefix + safeMimetypeStr) {
|
||||||
|
result = safeMimetypeStrTrns;
|
||||||
|
} else if (defaultTrns != langPrefix + 'default') {
|
||||||
|
result = defaultTrns;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capitalise) {
|
||||||
|
result = CoreTextUtils.instance.ucFirst(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the "type" (string) of a mimetype, something like "image", "video" or "audio".
|
||||||
|
*
|
||||||
|
* @param mimetype Mimetype.
|
||||||
|
* @return Type of the mimetype.
|
||||||
|
*/
|
||||||
|
getMimetypeType(mimetype: string): string {
|
||||||
|
mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any.
|
||||||
|
|
||||||
|
const extensions = this.mimeToExt[mimetype];
|
||||||
|
if (!extensions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < extensions.length; i++) {
|
||||||
|
const extension = extensions[i];
|
||||||
|
if (this.extToMime[extension] && this.extToMime[extension].string) {
|
||||||
|
return this.extToMime[extension].string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the icon of a mimetype.
|
||||||
|
*
|
||||||
|
* @param mimetype Mimetype.
|
||||||
|
* @return Type of the mimetype.
|
||||||
|
*/
|
||||||
|
getMimetypeIcon(mimetype: string): string {
|
||||||
|
mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any.
|
||||||
|
|
||||||
|
const extensions = this.mimeToExt[mimetype] || [];
|
||||||
|
let icon = 'unknown';
|
||||||
|
|
||||||
|
for (let i = 0; i < extensions.length; i++) {
|
||||||
|
const iconName = this.getExtensionIconName(extensions[i]);
|
||||||
|
|
||||||
|
if (iconName) {
|
||||||
|
icon = iconName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getFileIconForType(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a group name, return the translated name.
|
||||||
|
*
|
||||||
|
* @param name Group name.
|
||||||
|
* @return Translated name.
|
||||||
|
*/
|
||||||
|
getTranslatedGroupName(name: string): string {
|
||||||
|
const key = 'assets.mimetypes.group:' + name;
|
||||||
|
const translated = Translate.instance.instant(key);
|
||||||
|
|
||||||
|
return translated != key ? translated : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an extension belongs to at least one of the groups.
|
||||||
|
* Similar to Moodle's file_mimetype_in_typegroup, but using the extension instead of mimetype.
|
||||||
|
*
|
||||||
|
* @param extension Extension.
|
||||||
|
* @param groups List of groups to check.
|
||||||
|
* @return Whether the extension belongs to any of the groups.
|
||||||
|
*/
|
||||||
|
isExtensionInGroup(extension: string, groups: string[]): boolean {
|
||||||
|
extension = this.cleanExtension(extension);
|
||||||
|
|
||||||
|
if (groups && groups.length && this.extToMime[extension] && this.extToMime[extension].groups) {
|
||||||
|
for (let i = 0; i < this.extToMime[extension].groups.length; i++) {
|
||||||
|
const group = this.extToMime[extension].groups[i];
|
||||||
|
if (groups.indexOf(group) != -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the extension from a path (if any).
|
||||||
|
*
|
||||||
|
* @param path Path.
|
||||||
|
* @return Path without extension.
|
||||||
|
*/
|
||||||
|
removeExtension(path: string): string {
|
||||||
|
const position = path.lastIndexOf('.');
|
||||||
|
let extension;
|
||||||
|
|
||||||
|
if (position > -1) {
|
||||||
|
// Check extension corresponds to a mimetype to know if it's valid.
|
||||||
|
extension = path.substr(position + 1).toLowerCase();
|
||||||
|
if (typeof this.getMimeType(extension) != 'undefined') {
|
||||||
|
return path.substr(0, position); // Remove extension.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreMimetypeUtils extends makeSingleton(CoreMimetypeUtilsProvider) {}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,371 @@
|
||||||
|
// (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 * as moment from 'moment';
|
||||||
|
import { CoreConstants } from '@core/constants';
|
||||||
|
import { makeSingleton, Translate } from '@singletons/core.singletons';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Utils" service with helper functions for date and time.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreTimeUtilsProvider {
|
||||||
|
|
||||||
|
protected FORMAT_REPLACEMENTS = { // To convert PHP strf format to Moment format.
|
||||||
|
'%a': 'ddd',
|
||||||
|
'%A': 'dddd',
|
||||||
|
'%d': 'DD',
|
||||||
|
'%e': 'D', // Not exactly the same. PHP adds a space instead of leading zero, Moment doesn't.
|
||||||
|
'%j': 'DDDD',
|
||||||
|
'%u': 'E',
|
||||||
|
'%w': 'e', // It might not behave exactly like PHP, the first day could be calculated differently.
|
||||||
|
'%U': 'ww', // It might not behave exactly like PHP, the first week could be calculated differently.
|
||||||
|
'%V': 'WW',
|
||||||
|
'%W': 'ww', // It might not behave exactly like PHP, the first week could be calculated differently.
|
||||||
|
'%b': 'MMM',
|
||||||
|
'%B': 'MMMM',
|
||||||
|
'%h': 'MMM',
|
||||||
|
'%m': 'MM',
|
||||||
|
'%C' : '', // Not supported by Moment.
|
||||||
|
'%g': 'GG',
|
||||||
|
'%G': 'GGGG',
|
||||||
|
'%y': 'YY',
|
||||||
|
'%Y': 'YYYY',
|
||||||
|
'%H': 'HH',
|
||||||
|
'%k': 'H', // Not exactly the same. PHP adds a space instead of leading zero, Moment doesn't.
|
||||||
|
'%I': 'hh',
|
||||||
|
'%l': 'h', // Not exactly the same. PHP adds a space instead of leading zero, Moment doesn't.
|
||||||
|
'%M': 'mm',
|
||||||
|
'%p': 'A',
|
||||||
|
'%P': 'a',
|
||||||
|
'%r': 'hh:mm:ss A',
|
||||||
|
'%R': 'HH:mm',
|
||||||
|
'%S': 'ss',
|
||||||
|
'%T': 'HH:mm:ss',
|
||||||
|
'%X': 'LTS',
|
||||||
|
'%z': 'ZZ',
|
||||||
|
'%Z': 'ZZ', // Not supported by Moment, it was deprecated. Use the same as %z.
|
||||||
|
'%c': 'LLLL',
|
||||||
|
'%D': 'MM/DD/YY',
|
||||||
|
'%F': 'YYYY-MM-DD',
|
||||||
|
'%s': 'X',
|
||||||
|
'%x': 'L',
|
||||||
|
'%n': '\n',
|
||||||
|
'%t': '\t',
|
||||||
|
'%%': '%'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a PHP format to a Moment format.
|
||||||
|
*
|
||||||
|
* @param format PHP format.
|
||||||
|
* @return Converted format.
|
||||||
|
*/
|
||||||
|
convertPHPToMoment(format: string): string {
|
||||||
|
if (typeof format != 'string') {
|
||||||
|
// Not valid.
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let converted = '';
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < format.length; i++) {
|
||||||
|
let char = format[i];
|
||||||
|
|
||||||
|
if (char == '%') {
|
||||||
|
// It's a PHP format, try to convert it.
|
||||||
|
i++;
|
||||||
|
char += format[i] || '';
|
||||||
|
|
||||||
|
if (escaping) {
|
||||||
|
// We were escaping some characters, stop doing it now.
|
||||||
|
escaping = false;
|
||||||
|
converted += ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
converted += typeof this.FORMAT_REPLACEMENTS[char] != 'undefined' ? this.FORMAT_REPLACEMENTS[char] : char;
|
||||||
|
} else {
|
||||||
|
// Not a PHP format. We need to escape them, otherwise the letters could be confused with Moment formats.
|
||||||
|
if (!escaping) {
|
||||||
|
escaping = true;
|
||||||
|
converted += '[';
|
||||||
|
}
|
||||||
|
|
||||||
|
converted += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escaping) {
|
||||||
|
// Finish escaping.
|
||||||
|
converted += ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix format to use in an ion-datetime.
|
||||||
|
*
|
||||||
|
* @param format Format to use.
|
||||||
|
* @return Fixed format.
|
||||||
|
*/
|
||||||
|
fixFormatForDatetime(format: string): string {
|
||||||
|
if (!format) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The component ion-datetime doesn't support escaping characters ([]), so we remove them.
|
||||||
|
let fixed = format.replace(/[\[\]]/g, '');
|
||||||
|
|
||||||
|
if (fixed.indexOf('A') != -1) {
|
||||||
|
// Do not use am/pm format because there is a bug in ion-datetime.
|
||||||
|
fixed = fixed.replace(/ ?A/g, '');
|
||||||
|
fixed = fixed.replace(/h/g, 'H');
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns hours, minutes and seconds in a human readable format
|
||||||
|
*
|
||||||
|
* @param seconds A number of seconds
|
||||||
|
* @return Seconds in a human readable format.
|
||||||
|
*/
|
||||||
|
formatTime(seconds: number): string {
|
||||||
|
const totalSecs = Math.abs(seconds);
|
||||||
|
const years = Math.floor(totalSecs / CoreConstants.SECONDS_YEAR);
|
||||||
|
let remainder = totalSecs - (years * CoreConstants.SECONDS_YEAR);
|
||||||
|
const days = Math.floor(remainder / CoreConstants.SECONDS_DAY);
|
||||||
|
|
||||||
|
remainder = totalSecs - (days * CoreConstants.SECONDS_DAY);
|
||||||
|
|
||||||
|
const hours = Math.floor(remainder / CoreConstants.SECONDS_HOUR);
|
||||||
|
remainder = remainder - (hours * CoreConstants.SECONDS_HOUR);
|
||||||
|
|
||||||
|
const mins = Math.floor(remainder / CoreConstants.SECONDS_MINUTE);
|
||||||
|
const secs = remainder - (mins * CoreConstants.SECONDS_MINUTE);
|
||||||
|
|
||||||
|
const ss = Translate.instance.instant('core.' + (secs == 1 ? 'sec' : 'secs'));
|
||||||
|
const sm = Translate.instance.instant('core.' + (mins == 1 ? 'min' : 'mins'));
|
||||||
|
const sh = Translate.instance.instant('core.' + (hours == 1 ? 'hour' : 'hours'));
|
||||||
|
const sd = Translate.instance.instant('core.' + (days == 1 ? 'day' : 'days'));
|
||||||
|
const sy = Translate.instance.instant('core.' + (years == 1 ? 'year' : 'years'));
|
||||||
|
let oyears = '';
|
||||||
|
let odays = '';
|
||||||
|
let ohours = '';
|
||||||
|
let omins = '';
|
||||||
|
let osecs = '';
|
||||||
|
|
||||||
|
if (years) {
|
||||||
|
oyears = years + ' ' + sy;
|
||||||
|
}
|
||||||
|
if (days) {
|
||||||
|
odays = days + ' ' + sd;
|
||||||
|
}
|
||||||
|
if (hours) {
|
||||||
|
ohours = hours + ' ' + sh;
|
||||||
|
}
|
||||||
|
if (mins) {
|
||||||
|
omins = mins + ' ' + sm;
|
||||||
|
}
|
||||||
|
if (secs) {
|
||||||
|
osecs = secs + ' ' + ss;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (years) {
|
||||||
|
return oyears + ' ' + odays;
|
||||||
|
}
|
||||||
|
if (days) {
|
||||||
|
return odays + ' ' + ohours;
|
||||||
|
}
|
||||||
|
if (hours) {
|
||||||
|
return ohours + ' ' + omins;
|
||||||
|
}
|
||||||
|
if (mins) {
|
||||||
|
return omins + ' ' + osecs;
|
||||||
|
}
|
||||||
|
if (secs) {
|
||||||
|
return osecs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Translate.instance.instant('core.now');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns hours, minutes and seconds in a human readable format.
|
||||||
|
*
|
||||||
|
* @param duration Duration in seconds
|
||||||
|
* @param precision Number of elements to have in precision. 0 or undefined to full precission.
|
||||||
|
* @return Duration in a human readable format.
|
||||||
|
*/
|
||||||
|
formatDuration(duration: number, precision?: number): string {
|
||||||
|
precision = precision || 5;
|
||||||
|
|
||||||
|
const eventDuration = moment.duration(duration, 'seconds');
|
||||||
|
let durationString = '';
|
||||||
|
|
||||||
|
if (precision && eventDuration.years() > 0) {
|
||||||
|
durationString += ' ' + moment.duration(eventDuration.years(), 'years').humanize();
|
||||||
|
precision--;
|
||||||
|
}
|
||||||
|
if (precision && eventDuration.months() > 0) {
|
||||||
|
durationString += ' ' + moment.duration(eventDuration.months(), 'months').humanize();
|
||||||
|
precision--;
|
||||||
|
}
|
||||||
|
if (precision && eventDuration.days() > 0) {
|
||||||
|
durationString += ' ' + moment.duration(eventDuration.days(), 'days').humanize();
|
||||||
|
precision--;
|
||||||
|
}
|
||||||
|
if (precision && eventDuration.hours() > 0) {
|
||||||
|
durationString += ' ' + moment.duration(eventDuration.hours(), 'hours').humanize();
|
||||||
|
precision--;
|
||||||
|
}
|
||||||
|
if (precision && eventDuration.minutes() > 0) {
|
||||||
|
durationString += ' ' + moment.duration(eventDuration.minutes(), 'minutes').humanize();
|
||||||
|
precision--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return durationString.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns duration in a short human readable format: minutes and seconds, in fromat: 3' 27''.
|
||||||
|
*
|
||||||
|
* @param duration Duration in seconds
|
||||||
|
* @return Duration in a short human readable format.
|
||||||
|
*/
|
||||||
|
formatDurationShort(duration: number): string {
|
||||||
|
|
||||||
|
const minutes = Math.floor(duration / 60);
|
||||||
|
const seconds = duration - minutes * 60;
|
||||||
|
const durations = [];
|
||||||
|
|
||||||
|
if (minutes > 0) {
|
||||||
|
durations.push(minutes + '\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds > 0 || minutes === 0) {
|
||||||
|
durations.push(seconds + '\'\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
return durations.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current timestamp in a "readable" format: YYYYMMDDHHmmSS.
|
||||||
|
*
|
||||||
|
* @return The readable timestamp.
|
||||||
|
*/
|
||||||
|
readableTimestamp(): string {
|
||||||
|
return moment(Date.now()).format('YYYYMMDDHHmmSS');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current timestamp (UNIX format, seconds).
|
||||||
|
*
|
||||||
|
* @return The current timestamp in seconds.
|
||||||
|
*/
|
||||||
|
timestamp(): number {
|
||||||
|
return Math.round(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a timestamp into a readable date.
|
||||||
|
*
|
||||||
|
* @param timestamp Timestamp in milliseconds.
|
||||||
|
* @param format The format to use (lang key). Defaults to core.strftimedaydatetime.
|
||||||
|
* @param convert If true (default), convert the format from PHP to Moment. Set it to false for Moment formats.
|
||||||
|
* @param fixDay If true (default) then the leading zero from %d is removed.
|
||||||
|
* @param fixHour If true (default) then the leading zero from %I is removed.
|
||||||
|
* @return Readable date.
|
||||||
|
*/
|
||||||
|
userDate(timestamp: number, format?: string, convert: boolean = true, fixDay: boolean = true, fixHour: boolean = true): string {
|
||||||
|
format = Translate.instance.instant(format ? format : 'core.strftimedaydatetime');
|
||||||
|
|
||||||
|
if (fixDay) {
|
||||||
|
format = format.replace(/%d/g, '%e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixHour) {
|
||||||
|
format = format.replace('%I', '%l');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format could be in PHP format, convert it to moment.
|
||||||
|
if (convert) {
|
||||||
|
format = this.convertPHPToMoment(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
return moment(timestamp).format(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a timestamp to the format to set to a datetime input.
|
||||||
|
*
|
||||||
|
* @param timestamp Timestamp to convert (in ms). If not provided, current time.
|
||||||
|
* @return Formatted time.
|
||||||
|
*/
|
||||||
|
toDatetimeFormat(timestamp?: number): string {
|
||||||
|
timestamp = timestamp || Date.now();
|
||||||
|
|
||||||
|
return this.userDate(timestamp, 'YYYY-MM-DDTHH:mm:ss.SSS', false) + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a text into user timezone timestamp.
|
||||||
|
*
|
||||||
|
* @param date To convert to timestamp.
|
||||||
|
* @return Converted timestamp.
|
||||||
|
*/
|
||||||
|
convertToTimestamp(date: string): number {
|
||||||
|
if (typeof date == 'string' && date.slice(-1) == 'Z') {
|
||||||
|
return moment(date).unix() - (moment().utcOffset() * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
return moment(date).unix();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the localized ISO format (i.e DDMMYY) from the localized moment format. Useful for translations.
|
||||||
|
* DO NOT USE this function for ion-datetime format. Moment escapes characters with [], but ion-datetime doesn't support it.
|
||||||
|
*
|
||||||
|
* @param localizedFormat Format to use.
|
||||||
|
* @return Localized ISO format
|
||||||
|
*/
|
||||||
|
getLocalizedDateFormat(localizedFormat: any): string {
|
||||||
|
return moment.localeData().longDateFormat(localizedFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a given timestamp get the midnight value in the user's timezone.
|
||||||
|
*
|
||||||
|
* The calculation is performed relative to the user's midnight timestamp
|
||||||
|
* for today to ensure that timezones are preserved.
|
||||||
|
*
|
||||||
|
* @param timestamp The timestamp to calculate from. If not defined, return today's midnight.
|
||||||
|
* @return The midnight value of the user's timestamp.
|
||||||
|
*/
|
||||||
|
getMidnightForTimestamp(timestamp?: number): number {
|
||||||
|
if (timestamp) {
|
||||||
|
return moment(timestamp * 1000).startOf('day').unix();
|
||||||
|
} else {
|
||||||
|
return moment().startOf('day').unix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreTimeUtils extends makeSingleton(CoreTimeUtilsProvider) {}
|
|
@ -0,0 +1,520 @@
|
||||||
|
// (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 { CoreLang } from '@services/lang';
|
||||||
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
|
import CoreConfigConstants from '@app/config.json';
|
||||||
|
import { makeSingleton } from '@singletons/core.singletons';
|
||||||
|
import { CoreUrl } from '@singletons/url';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Utils" service with helper functions for URLs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoreUrlUtilsProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or remove 'www' from a URL. The url needs to have http or https protocol.
|
||||||
|
*
|
||||||
|
* @param url URL to modify.
|
||||||
|
* @return Modified URL.
|
||||||
|
*/
|
||||||
|
addOrRemoveWWW(url: string): string {
|
||||||
|
if (url) {
|
||||||
|
if (url.match(/http(s)?:\/\/www\./)) {
|
||||||
|
// Already has www. Remove it.
|
||||||
|
url = url.replace('www.', '');
|
||||||
|
} else {
|
||||||
|
url = url.replace('https://', 'https://www.');
|
||||||
|
url = url.replace('http://', 'http://www.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add params to a URL.
|
||||||
|
*
|
||||||
|
* @param url URL to add the params to.
|
||||||
|
* @param params Object with the params to add.
|
||||||
|
* @param anchor Anchor text if needed.
|
||||||
|
* @param boolToNumber Whether to convert bools to 1 or 0.
|
||||||
|
* @return URL with params.
|
||||||
|
*/
|
||||||
|
addParamsToUrl(url: string, params?: {[key: string]: any}, anchor?: string, boolToNumber?: boolean): string {
|
||||||
|
let separator = url.indexOf('?') != -1 ? '&' : '?';
|
||||||
|
|
||||||
|
for (const key in params) {
|
||||||
|
let value = params[key];
|
||||||
|
|
||||||
|
if (boolToNumber && typeof value == 'boolean') {
|
||||||
|
// Convert booleans to 1 or 0.
|
||||||
|
value = value ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore objects.
|
||||||
|
if (typeof value != 'object') {
|
||||||
|
url += separator + key + '=' + value;
|
||||||
|
separator = '&';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor) {
|
||||||
|
url += '#' + anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a URL and a text, return an HTML link.
|
||||||
|
*
|
||||||
|
* @param url URL.
|
||||||
|
* @param text Text of the link.
|
||||||
|
* @return Link.
|
||||||
|
*/
|
||||||
|
buildLink(url: string, text: string): string {
|
||||||
|
return '<a href="' + url + '">' + text + '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether we can use tokenpluginfile.php endpoint for a certain URL.
|
||||||
|
*
|
||||||
|
* @param url URL to check.
|
||||||
|
* @param siteUrl The URL of the site the URL belongs to.
|
||||||
|
* @param accessKey User access key for tokenpluginfile.
|
||||||
|
* @return Whether tokenpluginfile.php can be used.
|
||||||
|
*/
|
||||||
|
canUseTokenPluginFile(url: string, siteUrl: string, accessKey?: string): boolean {
|
||||||
|
// Do not use tokenpluginfile if site doesn't use slash params, the URL doesn't work.
|
||||||
|
// Also, only use it for "core" pluginfile endpoints. Some plugins can implement their own endpoint (like customcert).
|
||||||
|
return accessKey && !url.match(/[\&?]file=/) && (
|
||||||
|
url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'pluginfile.php')) === 0 ||
|
||||||
|
url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'webservice/pluginfile.php')) === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the parameters from a URL and stores them in an object.
|
||||||
|
*
|
||||||
|
* @param url URL to treat.
|
||||||
|
* @return Object with the params.
|
||||||
|
*/
|
||||||
|
extractUrlParams(url: string): any {
|
||||||
|
const regex = /[?&]+([^=&]+)=?([^&]*)?/gi;
|
||||||
|
const subParamsPlaceholder = '@@@SUBPARAMS@@@';
|
||||||
|
const params: any = {};
|
||||||
|
const urlAndHash = url.split('#');
|
||||||
|
const questionMarkSplit = urlAndHash[0].split('?');
|
||||||
|
let subParams;
|
||||||
|
|
||||||
|
if (questionMarkSplit.length > 2) {
|
||||||
|
// There is more than one question mark in the URL. This can happen if any of the params is a URL with params.
|
||||||
|
// We only want to treat the first level of params, so we'll remove this second list of params and restore it later.
|
||||||
|
questionMarkSplit.splice(0, 2);
|
||||||
|
|
||||||
|
subParams = '?' + questionMarkSplit.join('?');
|
||||||
|
urlAndHash[0] = urlAndHash[0].replace(subParams, subParamsPlaceholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
urlAndHash[0].replace(regex, (match: string, key: string, value: string): string => {
|
||||||
|
params[key] = typeof value != 'undefined' ? CoreTextUtils.instance.decodeURIComponent(value) : '';
|
||||||
|
|
||||||
|
if (subParams) {
|
||||||
|
params[key] = params[key].replace(subParamsPlaceholder, subParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (urlAndHash.length > 1) {
|
||||||
|
// Remove the URL from the array.
|
||||||
|
urlAndHash.shift();
|
||||||
|
|
||||||
|
// Add the hash as a param with a special name. Use a join in case there is more than one #.
|
||||||
|
params.urlHash = urlAndHash.join('#');
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic function for adding the wstoken to Moodle urls and for pointing to the correct script.
|
||||||
|
* For download remote files from Moodle we need to use the special /webservice/pluginfile passing
|
||||||
|
* the ws token as a get parameter.
|
||||||
|
*
|
||||||
|
* @param url The url to be fixed.
|
||||||
|
* @param token Token to use.
|
||||||
|
* @param siteUrl The URL of the site the URL belongs to.
|
||||||
|
* @param accessKey User access key for tokenpluginfile.
|
||||||
|
* @return Fixed URL.
|
||||||
|
*/
|
||||||
|
fixPluginfileURL(url: string, token: string, siteUrl: string, accessKey?: string): string {
|
||||||
|
if (!url) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
url = url.replace(/&/g, '&');
|
||||||
|
|
||||||
|
const canUseTokenPluginFile = accessKey && this.canUseTokenPluginFile(url, siteUrl, accessKey);
|
||||||
|
|
||||||
|
// First check if we need to fix this url or is already fixed.
|
||||||
|
if (!canUseTokenPluginFile && url.indexOf('token=') != -1) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if is a valid URL (contains the pluginfile endpoint) and belongs to the site.
|
||||||
|
if (!this.isPluginFileUrl(url) || url.indexOf(CoreTextUtils.instance.addEndingSlash(siteUrl)) !== 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canUseTokenPluginFile) {
|
||||||
|
// Use tokenpluginfile.php.
|
||||||
|
url = url.replace(/(\/webservice)?\/pluginfile\.php/, '/tokenpluginfile.php/' + accessKey);
|
||||||
|
} else {
|
||||||
|
// Use pluginfile.php. Some webservices returns directly the correct download url, others not.
|
||||||
|
if (url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'pluginfile.php')) === 0) {
|
||||||
|
url = url.replace('/pluginfile', '/webservice/pluginfile');
|
||||||
|
}
|
||||||
|
|
||||||
|
url = this.addParamsToUrl(url, {token});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.addParamsToUrl(url, {offline: 1}); // Always send offline=1 (it's for external repositories).
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a URL, trim, lowercase, etc...
|
||||||
|
*
|
||||||
|
* @param url The url to be formatted.
|
||||||
|
* @return Fromatted url.
|
||||||
|
*/
|
||||||
|
formatURL(url: string): string {
|
||||||
|
url = url.trim();
|
||||||
|
|
||||||
|
// Check if the URL starts by http or https.
|
||||||
|
if (! /^http(s)?\:\/\/.*/i.test(url)) {
|
||||||
|
// Test first allways https.
|
||||||
|
url = 'https://' + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// http always in lowercase.
|
||||||
|
url = url.replace(/^http/i, 'http');
|
||||||
|
url = url.replace(/^https/i, 'https');
|
||||||
|
|
||||||
|
// Replace last slash.
|
||||||
|
url = url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the URL to the documentation of the app, based on Moodle version and current language.
|
||||||
|
*
|
||||||
|
* @param release Moodle release.
|
||||||
|
* @param page Docs page to go to.
|
||||||
|
* @return Promise resolved with the Moodle docs URL.
|
||||||
|
*/
|
||||||
|
getDocsUrl(release?: string, page: string = 'Mobile_app'): Promise<string> {
|
||||||
|
let docsUrl = 'https://docs.moodle.org/en/' + page;
|
||||||
|
|
||||||
|
if (typeof release != 'undefined') {
|
||||||
|
const version = release.substr(0, 3).replace('.', '');
|
||||||
|
// Check is a valid number.
|
||||||
|
if (Number(version) >= 24) {
|
||||||
|
// Append release number.
|
||||||
|
docsUrl = docsUrl.replace('https://docs.moodle.org/', 'https://docs.moodle.org/' + version + '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoreLang.instance.getCurrentLanguage().then((lang) => {
|
||||||
|
return docsUrl.replace('/en/', '/' + lang + '/');
|
||||||
|
}).catch(() => {
|
||||||
|
return docsUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Youtube Embed Video URL or null if not found.
|
||||||
|
*
|
||||||
|
* @param url URL
|
||||||
|
* @return Youtube Embed Video URL or null if not found.
|
||||||
|
*/
|
||||||
|
getYoutubeEmbedUrl(url: string): string {
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoId;
|
||||||
|
const params: any = {};
|
||||||
|
|
||||||
|
url = CoreTextUtils.instance.decodeHTML(url);
|
||||||
|
|
||||||
|
// Get the video ID.
|
||||||
|
let match = url.match(/^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/);
|
||||||
|
|
||||||
|
if (match && match[2].length === 11) {
|
||||||
|
videoId = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// No videoId, do not continue.
|
||||||
|
if (!videoId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now get the playlist (if any).
|
||||||
|
match = url.match(/[?&]list=([^#\&\?]+)/);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
params.list = match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now get the start time (if any).
|
||||||
|
match = url.match(/[?&]start=(\d+)/);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
params.start = parseInt(match[1], 10);
|
||||||
|
} else {
|
||||||
|
// No start param, but it could have a time param.
|
||||||
|
match = url.match(/[?&]t=(\d+h)?(\d+m)?(\d+s)?/);
|
||||||
|
if (match) {
|
||||||
|
params.start = (match[1] ? parseInt(match[1], 10) * 3600 : 0) + (match[2] ? parseInt(match[2], 10) * 60 : 0) +
|
||||||
|
(match[3] ? parseInt(match[3], 10) : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.addParamsToUrl('https://www.youtube.com/embed/' + videoId, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a URL, returns what's after the last '/' without params.
|
||||||
|
* Example:
|
||||||
|
* http://mysite.com/a/course.html?id=1 -> course.html
|
||||||
|
*
|
||||||
|
* @param url URL to treat.
|
||||||
|
* @return Last file without params.
|
||||||
|
*/
|
||||||
|
getLastFileWithoutParams(url: string): string {
|
||||||
|
let filename = url.substr(url.lastIndexOf('/') + 1);
|
||||||
|
if (filename.indexOf('?') != -1) {
|
||||||
|
filename = filename.substr(0, filename.indexOf('?'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the protocol from a URL.
|
||||||
|
* E.g. http://www.google.com returns 'http'.
|
||||||
|
*
|
||||||
|
* @param url URL to treat.
|
||||||
|
* @return Protocol, undefined if no protocol found.
|
||||||
|
*/
|
||||||
|
getUrlProtocol(url: string): string {
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = url.match(/^([^\/:\.\?]*):\/\//);
|
||||||
|
if (matches && matches[1]) {
|
||||||
|
return matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the scheme from a URL. Please notice that, if a URL has protocol, it will return the protocol.
|
||||||
|
* E.g. javascript:doSomething() returns 'javascript'.
|
||||||
|
*
|
||||||
|
* @param url URL to treat.
|
||||||
|
* @return Scheme, undefined if no scheme found.
|
||||||
|
*/
|
||||||
|
getUrlScheme(url: string): string {
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = url.match(/^([a-z][a-z0-9+\-.]*):/);
|
||||||
|
if (matches && matches[1]) {
|
||||||
|
return matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gets a username from a URL like: user@mysite.com.
|
||||||
|
*
|
||||||
|
* @param url URL to treat.
|
||||||
|
* @return Username. Undefined if no username found.
|
||||||
|
*/
|
||||||
|
getUsernameFromUrl(url: string): string {
|
||||||
|
if (url.indexOf('@') > -1) {
|
||||||
|
// Get URL without protocol.
|
||||||
|
const withoutProtocol = url.replace(/^[^?@\/]*:\/\//, '');
|
||||||
|
const matches = withoutProtocol.match(/[^@]*/);
|
||||||
|
|
||||||
|
// Make sure that @ is at the start of the URL, not in a param at the end.
|
||||||
|
if (matches && matches.length && !matches[0].match(/[\/|?]/)) {
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if a URL has any protocol (not a relative URL).
|
||||||
|
*
|
||||||
|
* @param url The url to test against the pattern.
|
||||||
|
* @return Whether the url is absolute.
|
||||||
|
*/
|
||||||
|
isAbsoluteURL(url: string): boolean {
|
||||||
|
return /^[^:]{2,}:\/\//i.test(url) || /^(tel:|mailto:|geo:)/.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if a URL is downloadable: plugin file OR theme/image.php OR gravatar.
|
||||||
|
*
|
||||||
|
* @param url The URL to test.
|
||||||
|
* @return Whether the URL is downloadable.
|
||||||
|
*/
|
||||||
|
isDownloadableUrl(url: string): boolean {
|
||||||
|
return this.isPluginFileUrl(url) || this.isThemeImageUrl(url) || this.isGravatarUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if a URL is a gravatar URL.
|
||||||
|
*
|
||||||
|
* @param url The URL to test.
|
||||||
|
* @return Whether the URL is a gravatar URL.
|
||||||
|
*/
|
||||||
|
isGravatarUrl(url: string): boolean {
|
||||||
|
return url && url.indexOf('gravatar.com/avatar') !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL uses http or https protocol.
|
||||||
|
*
|
||||||
|
* @param url The url to test.
|
||||||
|
* @return Whether the url uses http or https protocol.
|
||||||
|
*/
|
||||||
|
isHttpURL(url: string): boolean {
|
||||||
|
return /^https?\:\/\/.+/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an URL belongs to a local file.
|
||||||
|
*
|
||||||
|
* @param url URL to check.
|
||||||
|
* @return Whether the URL belongs to a local file.
|
||||||
|
*/
|
||||||
|
isLocalFileUrl(url: string): boolean {
|
||||||
|
const urlParts = CoreUrl.parse(url);
|
||||||
|
|
||||||
|
return this.isLocalFileUrlScheme(urlParts.protocol, urlParts.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a URL scheme belongs to a local file.
|
||||||
|
*
|
||||||
|
* @param scheme Scheme to check.
|
||||||
|
* @param domain The domain. Needed because in Android the WebView scheme is http.
|
||||||
|
* @return Whether the scheme belongs to a local file.
|
||||||
|
*/
|
||||||
|
isLocalFileUrlScheme(scheme: string, domain: string): boolean {
|
||||||
|
if (scheme) {
|
||||||
|
scheme = scheme.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme == 'cdvfile' ||
|
||||||
|
scheme == 'file' ||
|
||||||
|
scheme == 'filesystem' ||
|
||||||
|
scheme == CoreConfigConstants.ioswebviewscheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if a URL is a pluginfile URL.
|
||||||
|
*
|
||||||
|
* @param url The URL to test.
|
||||||
|
* @return Whether the URL is a pluginfile URL.
|
||||||
|
*/
|
||||||
|
isPluginFileUrl(url: string): boolean {
|
||||||
|
return url && url.indexOf('/pluginfile.php') !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if a URL is a theme image URL.
|
||||||
|
*
|
||||||
|
* @param url The URL to test.
|
||||||
|
* @return Whether the URL is a theme image URL.
|
||||||
|
*/
|
||||||
|
isThemeImageUrl(url: string): boolean {
|
||||||
|
return url && url.indexOf('/theme/image.php') !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove protocol and www from a URL.
|
||||||
|
*
|
||||||
|
* @param url URL to treat.
|
||||||
|
* @return Treated URL.
|
||||||
|
*/
|
||||||
|
removeProtocolAndWWW(url: string): string {
|
||||||
|
// Remove protocol.
|
||||||
|
url = url.replace(/.*?:\/\//g, '');
|
||||||
|
// Remove www.
|
||||||
|
url = url.replace(/^www./, '');
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the parameters from a URL, returning the URL without them.
|
||||||
|
*
|
||||||
|
* @param url URL to treat.
|
||||||
|
* @return URL without params.
|
||||||
|
*/
|
||||||
|
removeUrlParams(url: string): string {
|
||||||
|
const matches = url.match(/^[^\?]+/);
|
||||||
|
|
||||||
|
return matches && matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifies a pluginfile URL to use the default pluginfile script instead of the webservice one.
|
||||||
|
*
|
||||||
|
* @param url The url to be fixed.
|
||||||
|
* @param siteUrl The URL of the site the URL belongs to.
|
||||||
|
* @return Modified URL.
|
||||||
|
*/
|
||||||
|
unfixPluginfileURL(url: string, siteUrl?: string): string {
|
||||||
|
if (!url) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
url = url.replace(/&/g, '&');
|
||||||
|
|
||||||
|
// It site URL is supplied, check if the URL belongs to the site.
|
||||||
|
if (siteUrl && url.indexOf(CoreTextUtils.instance.addEndingSlash(siteUrl)) !== 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a pluginfile URL. Treat webservice/pluginfile case.
|
||||||
|
url = url.replace(/\/webservice\/pluginfile\.php\//, '/pluginfile.php/');
|
||||||
|
|
||||||
|
// Make sure the URL doesn't contain the token.
|
||||||
|
url.replace(/([?&])token=[^&]*&?/, '$1');
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreUrlUtils extends makeSingleton(CoreUrlUtilsProvider) {}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,65 @@
|
||||||
|
// (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.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton with helper functions for arrays.
|
||||||
|
*/
|
||||||
|
export class CoreArray {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an array contains an item.
|
||||||
|
*
|
||||||
|
* @param arr Array.
|
||||||
|
* @param item Item.
|
||||||
|
* @return Whether item is within the array.
|
||||||
|
*/
|
||||||
|
static contains<T>(arr: T[], item: T): boolean {
|
||||||
|
return arr.indexOf(item) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the first dimension of a multi-dimensional array.
|
||||||
|
*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat#reduce_and_concat
|
||||||
|
*
|
||||||
|
* @param arr Original array.
|
||||||
|
* @return Flattened array.
|
||||||
|
*/
|
||||||
|
static flatten<T>(arr: T[][]): T[] {
|
||||||
|
if ('flat' in arr) {
|
||||||
|
return (arr as any).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [].concat(...arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain a new array without the specified item.
|
||||||
|
*
|
||||||
|
* @param arr Array.
|
||||||
|
* @param item Item to remove.
|
||||||
|
* @return Array without the specified item.
|
||||||
|
*/
|
||||||
|
static withoutItem<T>(arr: T[], item: T): T[] {
|
||||||
|
const newArray = [...arr];
|
||||||
|
const index = arr.indexOf(item);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
newArray.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,200 @@
|
||||||
|
// (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 { CoreTextUtils } from '@services/utils/text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parts contained within a url.
|
||||||
|
*/
|
||||||
|
interface UrlParts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url protocol.
|
||||||
|
*/
|
||||||
|
protocol?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url domain.
|
||||||
|
*/
|
||||||
|
domain?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url port.
|
||||||
|
*/
|
||||||
|
port?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url credentials: username and password (if any).
|
||||||
|
*/
|
||||||
|
credentials?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url's username.
|
||||||
|
*/
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url's password.
|
||||||
|
*/
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url path.
|
||||||
|
*/
|
||||||
|
path?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url query.
|
||||||
|
*/
|
||||||
|
query?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Url fragment.
|
||||||
|
*/
|
||||||
|
fragment?: string;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton with helper functions for urls.
|
||||||
|
*/
|
||||||
|
export class CoreUrl {
|
||||||
|
|
||||||
|
// Avoid creating singleton instances.
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse parts of a url, using an implicit protocol if it is missing from the url.
|
||||||
|
*
|
||||||
|
* @param url Url.
|
||||||
|
* @return Url parts.
|
||||||
|
*/
|
||||||
|
static parse(url: string): UrlParts | null {
|
||||||
|
// Parse url with regular expression taken from RFC 3986: https://tools.ietf.org/html/rfc3986#appendix-B.
|
||||||
|
const match = url.trim().match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = match[4] || '';
|
||||||
|
|
||||||
|
// Get the credentials and the port from the host.
|
||||||
|
const [domainAndPort, credentials]: string[] = host.split('@').reverse();
|
||||||
|
const [domain, port]: string[] = domainAndPort.split(':');
|
||||||
|
const [username, password]: string[] = credentials ? credentials.split(':') : [];
|
||||||
|
|
||||||
|
// Prepare parts replacing empty strings with undefined.
|
||||||
|
return {
|
||||||
|
protocol: match[2] || undefined,
|
||||||
|
domain: domain || undefined,
|
||||||
|
port: port || undefined,
|
||||||
|
credentials: credentials || undefined,
|
||||||
|
username: username || undefined,
|
||||||
|
password: password || undefined,
|
||||||
|
path: match[5] || undefined,
|
||||||
|
query: match[7] || undefined,
|
||||||
|
fragment: match[9] || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guess the Moodle domain from a site url.
|
||||||
|
*
|
||||||
|
* @param url Site url.
|
||||||
|
* @return Guessed Moodle domain.
|
||||||
|
*/
|
||||||
|
static guessMoodleDomain(url: string): string | null {
|
||||||
|
// Add protocol if it was missing. Moodle can only be served through http or https, so this is a fair assumption to make.
|
||||||
|
if (!url.match(/^https?:\/\//)) {
|
||||||
|
url = `https://${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match using common suffixes.
|
||||||
|
const knownSuffixes = [
|
||||||
|
'\/my\/?',
|
||||||
|
'\/\\\?redirect=0',
|
||||||
|
'\/index\\\.php',
|
||||||
|
'\/course\/view\\\.php',
|
||||||
|
'\/login\/index\\\.php',
|
||||||
|
'\/mod\/page\/view\\\.php',
|
||||||
|
];
|
||||||
|
const match = url.match(new RegExp(`^https?:\/\/(.*?)(${knownSuffixes.join('|')})`));
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing else worked, parse the domain.
|
||||||
|
const urlParts = CoreUrl.parse(url);
|
||||||
|
|
||||||
|
return urlParts && urlParts.domain ? urlParts.domain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the pattern to check if the URL is a valid Moodle Url.
|
||||||
|
*
|
||||||
|
* @return Desired RegExp.
|
||||||
|
*/
|
||||||
|
static getValidMoodleUrlPattern(): RegExp {
|
||||||
|
// Regular expression based on RFC 3986: https://tools.ietf.org/html/rfc3986#appendix-B.
|
||||||
|
// Improved to not admit spaces.
|
||||||
|
return new RegExp(/^(([^:/?# ]+):)?(\/\/([^/?# ]*))?([^?# ]*)(\?([^#]*))?(#(.*))?$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given url is valid for the app to connect.
|
||||||
|
*
|
||||||
|
* @param url Url to check.
|
||||||
|
* @return True if valid, false otherwise.
|
||||||
|
*/
|
||||||
|
static isValidMoodleUrl(url: string): boolean {
|
||||||
|
const patt = CoreUrl.getValidMoodleUrlPattern();
|
||||||
|
|
||||||
|
return patt.test(url.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes protocol from the url.
|
||||||
|
*
|
||||||
|
* @param url Site url.
|
||||||
|
* @return Url without protocol.
|
||||||
|
*/
|
||||||
|
static removeProtocol(url: string): string {
|
||||||
|
return url.replace(/^[a-zA-Z]+:\/\//i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two URLs have the same domain and path.
|
||||||
|
*
|
||||||
|
* @param urlA First URL.
|
||||||
|
* @param urlB Second URL.
|
||||||
|
* @return Whether they have same domain and path.
|
||||||
|
*/
|
||||||
|
static sameDomainAndPath(urlA: string, urlB: string): boolean {
|
||||||
|
// Add protocol if missing, the parse function requires it.
|
||||||
|
if (!urlA.match(/^[^\/:\.\?]*:\/\//)) {
|
||||||
|
urlA = `https://${urlA}`;
|
||||||
|
}
|
||||||
|
if (!urlB.match(/^[^\/:\.\?]*:\/\//)) {
|
||||||
|
urlB = `https://${urlB}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partsA = CoreUrl.parse(urlA);
|
||||||
|
const partsB = CoreUrl.parse(urlB);
|
||||||
|
|
||||||
|
return partsA.domain == partsB.domain &&
|
||||||
|
CoreTextUtils.instance.removeEndingSlash(partsA.path) == CoreTextUtils.instance.removeEndingSlash(partsB.path);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
// (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 { CoreFileHelper } from '@services/file-helper';
|
||||||
|
import { CoreSites } from '@services/sites';
|
||||||
|
import { CoreUrlUtils } from '@services/utils/url';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the open function.
|
||||||
|
*/
|
||||||
|
export type CoreWindowOpenOptions = {
|
||||||
|
/**
|
||||||
|
* NavController to use when opening the link in the app.
|
||||||
|
*/
|
||||||
|
navCtrl?: any; // @todo NavController;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton with helper functions for windows.
|
||||||
|
*/
|
||||||
|
export class CoreWindow {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Safe" implementation of window.open. It will open the URL without overriding the app.
|
||||||
|
*
|
||||||
|
* @param url URL to open.
|
||||||
|
* @param name Name of the browsing context into which to load the URL.
|
||||||
|
* @param options Other options.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
static async open(url: string, name?: string, options?: CoreWindowOpenOptions): Promise<void> {
|
||||||
|
if (CoreUrlUtils.instance.isLocalFileUrl(url)) {
|
||||||
|
const filename = url.substr(url.lastIndexOf('/') + 1);
|
||||||
|
|
||||||
|
if (!CoreFileHelper.instance.isOpenableInApp({ filename })) {
|
||||||
|
try {
|
||||||
|
await CoreFileHelper.instance.showConfirmOpenUnsupportedFile();
|
||||||
|
} catch (error) {
|
||||||
|
return; // Cancelled, stop.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await CoreUtils.instance.openFile(url);
|
||||||
|
} else {
|
||||||
|
let treated: boolean;
|
||||||
|
options = options || {};
|
||||||
|
|
||||||
|
if (name != '_system') {
|
||||||
|
// Check if it can be opened in the app.
|
||||||
|
treated = false; // @todo await CoreContentLinksHelper.instance.handleLink(url, undefined, options.navCtrl, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!treated) {
|
||||||
|
// Not opened in the app, open with browser. Check if we need to auto-login
|
||||||
|
if (!CoreSites.instance.isLoggedIn()) {
|
||||||
|
// Not logged in, cannot auto-login.
|
||||||
|
CoreUtils.instance.openInBrowser(url);
|
||||||
|
} else {
|
||||||
|
await CoreSites.instance.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue