MOBILE-3565 core: Migrate most core providers

main
Dani Palou 2020-10-07 10:53:19 +02:00
parent 811bb39781
commit e63a59eec1
31 changed files with 19027 additions and 53 deletions

View File

@ -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],

110
src/app/config.json 100644
View File

@ -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"
}
}

View File

@ -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();
}
}

View File

@ -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>;
};

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
};

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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

View File

@ -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) {}

View File

@ -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

View File

@ -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.
};

View File

@ -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

View File

@ -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) {}

View File

@ -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(/&amp;/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(/&amp;/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

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}
}
}