// (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 { Params } from '@angular/router'; import { Connection } from '@ionic-native/network/ngx'; import { CoreDB } from '@services/db'; import { CoreEvents } from '@singletons/events'; import { CoreUtils, PromiseDefer } from '@services/utils/utils'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { makeSingleton, Keyboard, Network, StatusBar, Platform, Device } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreColors } from '@singletons/colors'; import { DBNAME, SCHEMA_VERSIONS_TABLE_NAME, SCHEMA_VERSIONS_TABLE_SCHEMA, SchemaVersionsDBEntry } from '@services/database/app'; import { CoreNavHelper } from './nav-helper'; /** * Object responsible of managing schema versions. */ type SchemaVersionsManager = { get(schemaName: string): Promise; set(schemaName: string, version: number): Promise; }; /** * Factory to provide some global functionalities, like access to the global app database. * * @description * Each service or component should be responsible of creating their own database tables. Example: * * ```ts * constructor(appProvider: CoreAppProvider) { * this.appDB = appProvider.getDB(); * this.appDB.createTableFromSchema(this.tableSchema); * } * ``` */ @Injectable({ providedIn: 'root' }) export class CoreAppProvider { protected db: SQLiteDB; protected logger: CoreLogger; protected ssoAuthenticationDeferred?: PromiseDefer; protected isKeyboardShown = false; protected keyboardOpening = false; protected keyboardClosing = false; protected backActions: {callback: () => boolean; priority: number}[] = []; protected forceOffline = false; // Variables for DB. protected schemaVersionsManager: Promise; protected resolveSchemaVersionsManager!: (schemaVersionsManager: SchemaVersionsManager) => void; constructor() { this.schemaVersionsManager = new Promise(resolve => this.resolveSchemaVersionsManager = resolve); this.db = CoreDB.instance.getDB(DBNAME); this.logger = CoreLogger.getInstance('CoreAppProvider'); // @todo // this.platform.registerBackButtonAction(() => { // this.backButtonAction(); // }, 100); } /** * 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; } /** * Initialize database. */ async initializeDatabase(): Promise { await this.db.createTableFromSchema(SCHEMA_VERSIONS_TABLE_SCHEMA); this.resolveSchemaVersionsManager({ get: async name => { try { // Fetch installed version of the schema. const entry = await this.db.getRecord(SCHEMA_VERSIONS_TABLE_NAME, { name }); return entry.version; } catch (error) { // No installed version yet. return 0; } }, set: async (name, version) => { await this.db.insertRecord(SCHEMA_VERSIONS_TABLE_NAME, { name, version }); }, }); } /** * 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 !!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 { this.logger.debug(`Apply schema to app DB: ${schema.name}`); const schemaVersionsManager = await this.schemaVersionsManager; const oldVersion = await schemaVersionsManager.get(schema.name); 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. schemaVersionsManager.set(schema.name, 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. * @deprecated since 3.9.5. Use CoreNavHelperService.getMainMenuId instead. */ getMainMenuId(): number { return CoreNavHelper.instance.getMainMenuId(); } /** * Get app store URL. * * @param storesConfig Config params to send the user to the right place. * @return Store URL. */ getAppStoreUrl(storesConfig: CoreStoreConfig): string | undefined { 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; } /** * Get platform major version number. */ getPlatformMajorVersion(): number { if (!this.isMobile()) { return 0; } return Number(Device.instance.version?.split('.')[0]); } /** * Checks if the app is running in a 64 bits desktop environment (not browser). * * @return false. * @deprecated since 3.9.5 Desktop support has been removed. */ is64Bits(): boolean { return false; } /** * 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 false. * @deprecated since 3.9.5 Desktop support has been removed. */ isDesktop(): boolean { return false; } /** * 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.keyboardClosing; } /** * Check if the keyboard is being opened. * * @return Whether keyboard is opening (animating). */ isKeyboardOpening(): boolean { return this.keyboardOpening; } /** * 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 false. * @deprecated since 3.9.5 Desktop support has been removed. */ isLinux(): boolean { return false; } /** * Check if the app is running in a Mac OS environment. * * @return false. * @deprecated since 3.9.5 Desktop support has been removed. */ isMac(): boolean { return false; } /** * Check if the main menu is open. * * @return Whether the main menu is open. * @deprecated since 3.9.5. Use CoreNavHelperService.isMainMenuOpen instead. */ isMainMenuOpen(): boolean { return CoreNavHelper.instance.isMainMenuOpen(); } /** * 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 false. * @deprecated since 3.9.5 Desktop support has been removed. */ isWindows(): boolean { return false; } /** * Open the keyboard. */ openKeyboard(): void { // Open keyboard is not supported in desktop and in iOS. if (this.isAndroid()) { Keyboard.instance.show(); } } /** * Notify that Keyboard has been shown. * * @param keyboardHeight Keyboard height. */ onKeyboardShow(keyboardHeight: number): void { 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.trigger(CoreEvents.KEYBOARD_CHANGE, keyboardHeight); } /** * Notify that Keyboard has been hidden. */ onKeyboardHide(): void { document.body.classList.remove('keyboard-is-open'); this.setKeyboardShown(false); CoreEvents.trigger(CoreEvents.KEYBOARD_CHANGE, 0); } /** * Notify that Keyboard is about to be shown. */ onKeyboardWillShow(): void { this.keyboardOpening = true; this.keyboardClosing = false; } /** * Notify that Keyboard is about to be hidden. */ onKeyboardWillHide(): void { this.keyboardOpening = false; this.keyboardClosing = true; } /** * Set keyboard shown or hidden. * * @param Whether the keyboard is shown or hidden. */ protected setKeyboardShown(shown: boolean): void { this.isKeyboardShown = shown; this.keyboardOpening = false; this.keyboardClosing = false; } /** * Set a main menu as open or not. * * @param id Main menu ID. * @param open Whether it's open or not. * @deprecated since 3.9.5. Use CoreNavHelperService.setMainMenuOpen instead. */ setMainMenuOpen(id: number, open: boolean): void { CoreNavHelper.instance.setMainMenuOpen(id, open); } /** * 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 { this.ssoAuthenticationDeferred = CoreUtils.instance.promiseDefer(); // Resolve it automatically after 10 seconds (it should never take that long). const cancelTimeout = setTimeout(() => this.finishSSOAuthentication(), 10000); // If the promise is resolved because finishSSOAuthentication is called, stop the cancel promise. // eslint-disable-next-line promise/catch-or-return this.ssoAuthenticationDeferred.promise.then(() => clearTimeout(cancelTimeout)); } /** * Finish an SSO authentication process. */ finishSSOAuthentication(): void { if (this.ssoAuthenticationDeferred) { this.ssoAuthenticationDeferred.resolve(); this.ssoAuthenticationDeferred = undefined; } } /** * Check if there's an ongoing SSO authentication process. * * @return Whether there's a SSO authentication ongoing. */ isSSOAuthenticationOngoing(): boolean { return !!this.ssoAuthenticationDeferred; } /** * Returns a promise that will be resolved once SSO authentication finishes. * * @return Promise resolved once SSO authentication finishes. */ async waitForSSOAuthentication(): Promise { const promise = this.ssoAuthenticationDeferred?.promise; await promise; } /** * Wait until the application is resumed. * * @param timeout Maximum time to wait, use null to wait forever. */ async waitForResume(timeout: number | null = null): Promise { let deferred: PromiseDefer | null = CoreUtils.instance.promiseDefer(); const stopWaiting = () => { if (!deferred) { return; } deferred.resolve(); resumeSubscription.unsubscribe(); timeoutId && clearTimeout(timeoutId); deferred = null; }; const resumeSubscription = Platform.instance.resume.subscribe(stopWaiting); const timeoutId = timeout ? setTimeout(stopWaiting, timeout) : false; await deferred.promise; } /** * Retrieve redirect data. * * @return Object with siteid, state, params and timemodified. */ getRedirect(): CoreRedirectData { if (localStorage?.getItem) { try { const paramsJson = localStorage.getItem('CoreRedirectParams'); const data: CoreRedirectData = { siteId: localStorage.getItem('CoreRedirectSiteId') || undefined, page: localStorage.getItem('CoreRedirectState') || undefined, timemodified: parseInt(localStorage.getItem('CoreRedirectTime') || '0', 10), }; if (paramsJson) { data.params = JSON.parse(paramsJson); } return data; } catch (ex) { this.logger.error('Error loading redirect data:', ex); } } return {}; } /** * Store redirect params. * * @param siteId Site ID. * @param page Page to go. * @param params Page params. */ storeRedirect(siteId: string, page: string, params: Params): void { if (localStorage && localStorage.setItem) { try { localStorage.setItem('CoreRedirectSiteId', siteId); localStorage.setItem('CoreRedirectState', page); localStorage.setItem('CoreRedirectParams', JSON.stringify(params)); localStorage.setItem('CoreRedirectTime', String(Date.now())); } catch (ex) { // Ignore errors. } } } /** * 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 callback 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(callback: () => boolean, priority: number = 0): () => boolean { const action = { callback, priority }; this.backActions.push(action); this.backActions.sort((a, b) => 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. * * @param color RGB color to use as status bar background. If not set the css variable will be read. */ setStatusBarColor(color?: string): void { if (!color) { // Get the default color to reset it. color = getComputedStyle(document.documentElement).getPropertyValue('--ion-statusbar-background').trim(); } // Make darker on Android. if (this.isAndroid()) { color = CoreColors.darker(color); } const useLightText = CoreColors.isWhiteContrastingBetter(color); const statusBar = StatusBar.instance; statusBar.backgroundColorByHexString(color); useLightText ? statusBar.styleLightContent() : statusBar.styleDefault(); this.isIOS() && statusBar.overlaysWebView(false); } /** * Reset StatusBar color if any was set. * * @deprecated Use setStatusBarColor passing the color of the new statusbar color loaded on remote theme or no color to reset. */ resetStatusBarColor(): void { this.setStatusBarColor(); } /** * 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) {} /** * 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?: Params; /** * Timestamp when this redirect was last modified. */ timemodified?: number; }; /** * Store config data. */ export type CoreStoreConfig = { /** * 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; };