From 727db6c4eaa12826580a95d324964adf3367876d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Oct 2020 08:55:46 +0200 Subject: [PATCH 01/10] MOBILE-3565 login: Initial implementation of site page --- src/app/classes/site.ts | 15 +- src/app/core/login/login.module.ts | 10 + src/app/core/login/pages/init/init.page.ts | 7 +- src/app/core/login/pages/site/site.html | 106 +- src/app/core/login/pages/site/site.page.ts | 485 ++++++- src/app/core/login/pages/site/site.scss | 130 +- src/app/core/login/services/helper.ts | 1327 ++++++++++++++++++++ src/app/services/events.ts | 10 + src/app/services/sites.ts | 2 +- src/app/services/utils/dom.ts | 5 + src/assets/img/login_logo.png | Bin 0 -> 16563 bytes 11 files changed, 2083 insertions(+), 14 deletions(-) create mode 100644 src/app/core/login/services/helper.ts create mode 100644 src/assets/img/login_logo.png diff --git a/src/app/classes/site.ts b/src/app/classes/site.ts index 97cad6e9c..c02a3259f 100644 --- a/src/app/classes/site.ts +++ b/src/app/classes/site.ts @@ -2112,11 +2112,7 @@ export type CoreSitePublicConfigResponse = { mobilecssurl?: string; // Mobile custom CSS theme. // eslint-disable-next-line @typescript-eslint/naming-convention tool_mobile_disabledfeatures?: string; // Disabled features in the app. - identityproviders?: { // Identity providers. - name: string; // The identity provider name. - iconurl: string; // The icon URL for the provider. - url: string; // The URL of the provider. - }[]; + identityproviders?: CoreSiteIdentityProvider[]; // Identity providers. country?: string; // Default site country. agedigitalconsentverification?: boolean; // Whether age digital consent verification is enabled. supportname?: string; // Site support contact name (only if age verification is enabled). @@ -2137,6 +2133,15 @@ export type CoreSitePublicConfigResponse = { warnings?: CoreWSExternalWarning[]; }; +/** + * Identity provider. + */ +export type CoreSiteIdentityProvider = { + name: string; // The identity provider name. + iconurl: string; // The icon URL for the provider. + url: string; // The URL of the provider. +}; + /** * Result of WS tool_mobile_get_autologin_key. */ diff --git a/src/app/core/login/login.module.ts b/src/app/core/login/login.module.ts index 39ba3ce72..f3e05da4d 100644 --- a/src/app/core/login/login.module.ts +++ b/src/app/core/login/login.module.ts @@ -14,13 +14,17 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@/app/components/components.module'; + import { CoreLoginRoutingModule } from './login-routing.module'; import { CoreLoginInitPage } from './pages/init/init.page'; import { CoreLoginSitePage } from './pages/site/site.page'; +import { CoreLoginHelperProvider } from './services/helper'; @NgModule({ imports: [ @@ -28,10 +32,16 @@ import { CoreLoginSitePage } from './pages/site/site.page'; IonicModule, CoreLoginRoutingModule, TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, ], declarations: [ CoreLoginInitPage, CoreLoginSitePage, ], + providers: [ + CoreLoginHelperProvider, + ], }) export class CoreLoginModule {} diff --git a/src/app/core/login/pages/init/init.page.ts b/src/app/core/login/pages/init/init.page.ts index 38e3cbab3..006ec48c6 100644 --- a/src/app/core/login/pages/init/init.page.ts +++ b/src/app/core/login/pages/init/init.page.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; +import { NavController } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreInit } from '@services/init'; @@ -29,7 +29,7 @@ import { SplashScreen } from '@singletons/core.singletons'; }) export class CoreLoginInitPage implements OnInit { - constructor(protected router: Router) {} + constructor(protected navCtrl: NavController) {} /** * Initialize the component. @@ -90,6 +90,7 @@ export class CoreLoginInitPage implements OnInit { // return this.loginHelper.goToSiteInitialPage(); // } - await this.router.navigate(['/login/site']); + await this.navCtrl.navigateRoot('/login/site'); } + } diff --git a/src/app/core/login/pages/site/site.html b/src/app/core/login/pages/site/site.html index e2beae84f..811b44a34 100644 --- a/src/app/core/login/pages/site/site.html +++ b/src/app/core/login/pages/site/site.html @@ -1,3 +1,105 @@ - - {{ 'core.login.yourenteredsite' | translate }} + + + + + + + {{ 'core.login.connecttomoodle' | translate }} + + + + + + + +
+ +
+ + + +

{{ 'core.login.siteaddress' | translate }}

+ +
+
+ + +

{{ 'core.login.siteaddress' | translate }}

+ +
+ + + + +
+ + + {{ 'core.login.connect' | translate }} + +
+ + + + +

{{ 'core.login.selectsite' | translate }}

+ + + + + + +

{{site.title}}

+

{{site.noProtocolUrl}}

+

{{site.location}}

+
+
+ + +
+

{{ 'core.login.selectsite' | translate }}

+ {{site.title}} +
+
+ + + + + + + + + + +
diff --git a/src/app/core/login/pages/site/site.page.ts b/src/app/core/login/pages/site/site.page.ts index 12e0398ae..8183d1e68 100644 --- a/src/app/core/login/pages/site/site.page.ts +++ b/src/app/core/login/pages/site/site.page.ts @@ -12,7 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, FormGroup, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; + +import { CoreApp } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreSites, CoreSiteCheckResponse, CoreLoginSiteInfo, CoreSitesDemoSiteData } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/helper'; +import { CoreSite } from '@classes/site'; +import { CoreError } from '@classes/errors/error'; +import CoreConfigConstants from '@app/config.json'; +import { Translate } from '@singletons/core.singletons'; +import { CoreUrl } from '@singletons/url'; +import { CoreUrlUtils } from '@/app/services/utils/url'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -24,11 +39,477 @@ import { Component, OnInit } from '@angular/core'; }) export class CoreLoginSitePage implements OnInit { + @ViewChild('siteFormEl') formElement: ElementRef; + + siteForm: FormGroup; + fixedSites: CoreLoginSiteInfoExtended[]; + filteredSites: CoreLoginSiteInfoExtended[]; + siteSelector = 'sitefinder'; + showKeyboard = false; + filter = ''; + sites: CoreLoginSiteInfoExtended[] = []; + hasSites = false; + loadingSites = false; + searchFunction: (search: string) => void; + showScanQR: boolean; + enteredSiteUrl: CoreLoginSiteInfoExtended; + siteFinderSettings: SiteFinderSettings; + + constructor( + protected route: ActivatedRoute, + protected formBuilder: FormBuilder, + ) { + + let url = ''; + this.siteSelector = CoreConfigConstants.multisitesdisplay; + + const siteFinderSettings: Partial = CoreConfigConstants['sitefindersettings'] || {}; + this.siteFinderSettings = { + displaysitename: true, + displayimage: true, + displayalias: true, + displaycity: true, + displaycountry: true, + displayurl: true, + ...siteFinderSettings, + }; + + // Load fixed sites if they're set. + if (CoreLoginHelper.instance.hasSeveralFixedSites()) { + url = this.initSiteSelector(); + } else if (CoreConfigConstants.enableonboarding && !CoreApp.instance.isIOS() && !CoreApp.instance.isMac()) { + this.initOnboarding(); + } + + this.showScanQR = CoreUtils.instance.canScanQR() && (typeof CoreConfigConstants['displayqronsitescreen'] == 'undefined' || + !!CoreConfigConstants['displayqronsitescreen']); + + this.siteForm = this.formBuilder.group({ + siteUrl: [url, this.moodleUrlValidator()], + }); + + this.searchFunction = CoreUtils.instance.debounce(async (search: string) => { + search = search.trim(); + + if (search.length >= 3) { + // Update the sites list. + this.sites = await CoreSites.instance.findSites(search); + + // Add UI tweaks. + this.sites = this.extendCoreLoginSiteInfo(this.sites); + + this.hasSites = !!this.sites.length; + } else { + // Not reseting the array to allow animation to be displayed. + this.hasSites = false; + } + + this.loadingSites = false; + }, 1000); + } + /** * Initialize the component. */ ngOnInit(): void { - // + this.route.queryParams.subscribe(params => { + this.showKeyboard = !!params['showKeyboard']; + }); + } + + /** + * Initialize the site selector. + * + * @return URL of the first site. + */ + protected initSiteSelector(): string { + // Deprecate listnourl on 3.9.3, remove this block on the following release. + if (this.siteSelector == 'listnourl') { + this.siteSelector = 'list'; + this.siteFinderSettings.displayurl = false; + } + + this.fixedSites = this.extendCoreLoginSiteInfo( CoreLoginHelper.instance.getFixedSites()); + + // Do not show images if none are set. + if (!this.fixedSites.some((site) => !!site.imageurl)) { + this.siteFinderSettings.displayimage = false; + } + + // Autoselect if not defined. + if (this.siteSelector != 'list' && this.siteSelector != 'buttons') { + this.siteSelector = this.fixedSites.length > 3 ? 'list' : 'buttons'; + } + + this.filteredSites = this.fixedSites; + + return this.fixedSites[0].url; + } + + /** + * Initialize and show onboarding if needed. + * + * @return Promise resolved when done. + */ + protected async initOnboarding(): Promise { + const onboardingDone = await CoreConfig.instance.get(CoreLoginHelperProvider.ONBOARDING_DONE, false); + + if (!onboardingDone) { + // Check onboarding. + this.showOnboarding(); + } + } + + /** + * Extend info of Login Site Info to get UI tweaks. + * + * @param sites Sites list. + * @return Sites list with extended info. + */ + protected extendCoreLoginSiteInfo(sites: CoreLoginSiteInfoExtended[]): CoreLoginSiteInfoExtended[] { + return sites.map((site) => { + site.noProtocolUrl = this.siteFinderSettings.displayurl && site.url ? CoreUrl.removeProtocol(site.url) : ''; + + const name = this.siteFinderSettings.displaysitename ? site.name : ''; + const alias = this.siteFinderSettings.displayalias && site.alias ? site.alias : ''; + + // Set title with parenthesis if both name and alias are present. + site.title = name && alias ? name + ' (' + alias + ')' : name + alias; + + const country = this.siteFinderSettings.displaycountry && site.countrycode ? + CoreUtils.instance.getCountryName(site.countrycode) : ''; + const city = this.siteFinderSettings.displaycity && site.city ? + site.city : ''; + + // Separate location with hiphen if both country and city are present. + site.location = city && country ? city + ' - ' + country : city + country; + + return site; + }); + } + + /** + * Validate Url. + * + * @return {ValidatorFn} Validation results. + */ + protected moodleUrlValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value.trim(); + let valid = value.length >= 3 && CoreUrl.isValidMoodleUrl(value); + + if (!valid) { + const demo = !!this.getDemoSiteData(value); + + if (demo) { + valid = true; + } + } + + return valid ? null : { siteUrl: { value: control.value } }; + }; + } + + /** + * Get the demo data for a certain "name" if it is a demo site. + * + * @param name Name of the site to check. + * @return Site data if it's a demo site, undefined otherwise. + */ + getDemoSiteData(name: string): CoreSitesDemoSiteData { + const demoSites = CoreConfigConstants.demo_sites; + if (typeof demoSites != 'undefined' && typeof demoSites[name] != 'undefined') { + return demoSites[name]; + } + } + + /** + * Show a help modal. + */ + showHelp(): void { + // @todo + } + + /** + * Show an onboarding modal. + */ + showOnboarding(): void { + // @todo + } + + /** + * Try to connect to a site. + * + * @param e Event. + * @param url The URL to connect to. + * @param foundSite The site clicked, if any, from the found sites list. + * @return Promise resolved when done. + */ + async connect(e: Event, url: string, foundSite?: CoreLoginSiteInfoExtended): Promise { + e.preventDefault(); + e.stopPropagation(); + + CoreApp.instance.closeKeyboard(); + + if (!url) { + CoreDomUtils.instance.showErrorModal('core.login.siteurlrequired', true); + + return; + } + + if (!CoreApp.instance.isOnline()) { + CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true); + + return; + } + + url = url.trim(); + + if (url.match(/^(https?:\/\/)?campus\.example\.edu/)) { + this.showLoginIssue(null, new CoreError(Translate.instance.instant('core.login.errorexampleurl'))); + + return; + } + + const siteData = CoreSites.instance.getDemoSiteData(url); + + if (siteData) { + // It's a demo site. + await this.loginDemoSite(siteData); + + } else { + // Not a demo site. + const modal = await CoreDomUtils.instance.showModalLoading(); + + let checkResult: CoreSiteCheckResponse; + + try { + checkResult = await CoreSites.instance.checkSite(url); + } catch (error) { + // Attempt guessing the domain if the initial check failed + const domain = CoreUrl.guessMoodleDomain(url); + + if (domain && domain != url) { + try { + checkResult = await CoreSites.instance.checkSite(domain); + } catch (secondError) { + // Try to use the first error. + modal.dismiss(); + + return this.showLoginIssue(url, error || secondError); + } + } else { + modal.dismiss(); + + return this.showLoginIssue(url, error); + } + } + + await this.login(checkResult, foundSite); + + modal.dismiss(); + } + } + + /** + * Authenticate in a demo site. + * + * @param siteData Site data. + * @return Promise resolved when done. + */ + protected async loginDemoSite(siteData: CoreSitesDemoSiteData): Promise { + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const data = await CoreSites.instance.getUserToken(siteData.url, siteData.username, siteData.password); + + await CoreSites.instance.newSite(data.siteUrl, data.token, data.privateToken); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); + + return CoreLoginHelper.instance.goToSiteInitialPage(); + } catch (error) { + CoreLoginHelper.instance.treatUserTokenError(siteData.url, error, siteData.username, siteData.password); + + if (error.loggedout) { + // @todo Send the user to sites page. + } + } finally { + modal.dismiss(); + } + } + + /** + * Process login to a site. + * + * @param response Response obtained from the site check request. + * @param foundSite The site clicked, if any, from the found sites list. + * + * @return Promise resolved after logging in. + */ + protected async login(response: CoreSiteCheckResponse, foundSite?: CoreLoginSiteInfoExtended): Promise { + await CoreUtils.instance.ignoreErrors(CoreSites.instance.checkApplication(response)); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); + + if (response.warning) { + CoreDomUtils.instance.showErrorModal(response.warning, true, 4000); + } + + if (CoreLoginHelper.instance.isSSOLoginNeeded(response.code)) { + // SSO. User needs to authenticate in a browser. + CoreLoginHelper.instance.confirmAndOpenBrowserForSSOLogin( + response.siteUrl, response.code, response.service, response.config && response.config.launchurl); + } else { + const pageParams = { siteUrl: response.siteUrl, siteConfig: response.config }; + if (foundSite) { + pageParams['siteName'] = foundSite.name; + pageParams['logoUrl'] = foundSite.imageurl; + } + + // @todo Navigate to credentials. + } + } + + /** + * Show an error that aims people to solve the issue. + * + * @param url The URL the user was trying to connect to. + * @param error Error to display. + */ + protected showLoginIssue(url: string, error: CoreError): void { + let errorMessage = CoreDomUtils.instance.getErrorMessage(error); + + if (errorMessage == Translate.instance.instant('core.cannotconnecttrouble')) { + const found = this.sites.find((site) => site.url == url); + + if (!found) { + errorMessage += ' ' + Translate.instance.instant('core.cannotconnectverify'); + } + } + + let message = '

' + errorMessage + '

'; + if (url) { + const fullUrl = CoreUrlUtils.instance.isAbsoluteURL(url) ? url : 'https://' + url; + message += '

' + url + '

'; + } + + const buttons = [ + { + text: Translate.instance.instant('core.needhelp'), + handler: (): void => { + this.showHelp(); + }, + }, + { + text: Translate.instance.instant('core.tryagain'), + role: 'cancel', + }, + ]; + + // @TODO: Remove CoreSite.MINIMUM_MOODLE_VERSION, not used on translations since 3.9.0. + CoreDomUtils.instance.showAlertWithOptions({ + header: Translate.instance.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + message, + buttons, + }); + } + + /** + * The filter has changed. + * + * @param event Received Event. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filterChanged(event: any): void { + const newValue = event.target.value?.trim().toLowerCase(); + if (!newValue || !this.fixedSites) { + this.filteredSites = this.fixedSites; + } else { + this.filteredSites = this.fixedSites.filter((site) => + site.title.toLowerCase().indexOf(newValue) > -1 || site.noProtocolUrl.toLowerCase().indexOf(newValue) > -1 || + site.location.toLowerCase().indexOf(newValue) > -1); + } + } + + /** + * Find a site on the backend. + * + * @param e Event. + * @param search Text to search. + */ + searchSite(e: Event, search: string): void { + this.loadingSites = true; + + search = search.trim(); + + if (this.siteForm.valid && search.length >= 3) { + this.enteredSiteUrl = { + url: search, + name: 'connect', + noProtocolUrl: CoreUrl.removeProtocol(search), + }; + } else { + this.enteredSiteUrl = null; + } + + this.searchFunction(search.trim()); + } + + /** + * Show instructions and scan QR code. + */ + showInstructionsAndScanQR(): void { + // Show some instructions first. + CoreDomUtils.instance.showAlertWithOptions({ + header: Translate.instance.instant('core.login.faqwhereisqrcode'), + message: Translate.instance.instant('core.login.faqwhereisqrcodeanswer', + { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }), + buttons: [ + { + text: Translate.instance.instant('core.cancel'), + role: 'cancel', + }, + { + text: Translate.instance.instant('core.next'), + handler: (): void => { + this.scanQR(); + }, + }, + ], + }); + } + + /** + * Scan a QR code and put its text in the URL input. + * + * @return Promise resolved when done. + */ + async scanQR(): Promise { + // Scan for a QR code. + const text = await CoreUtils.instance.scanQR(); + + if (text) { + // @todo + } } } + +/** + * Extended data for UI implementation. + */ +type CoreLoginSiteInfoExtended = CoreLoginSiteInfo & { + noProtocolUrl?: string; // Url wihtout protocol. + location?: string; // City + country. + title?: string; // Name + alias. +}; + +type SiteFinderSettings = { + displayalias: boolean; + displaycity: boolean; + displaycountry: boolean; + displayimage: boolean; + displaysitename: boolean; + displayurl: boolean; +}; diff --git a/src/app/core/login/pages/site/site.scss b/src/app/core/login/pages/site/site.scss index b61725a0d..97390e520 100644 --- a/src/app/core/login/pages/site/site.scss +++ b/src/app/core/login/pages/site/site.scss @@ -1,2 +1,130 @@ -app-root page-core-login-init { +.item-input:last-child { + margin-bottom: 20px; +} + +.searchbar-ios { + background: transparent; + + .searchbar-input { + background-color: white; // @todo $searchbar-ios-toolbar-input-background; + } +} + +.item.item-block { + &.core-login-need-help.item { + text-decoration: underline; + } + &.core-login-site-qrcode { + .item-inner { + border-bottom: 0; + } + } +} + +.core-login-site-connect { + margin-top: 1.4rem; +} + +.item ion-thumbnail { + min-width: 50px; + min-height: 50px; + width: 50px; + height: 50px; + border-radius: 20%; + box-shadow: 0 0 4px #eee; + text-align: center; + overflow: hidden; + + img { + max-height: 50px; + max-width: fit-content; + width: auto; + height: auto; + margin: 0 auto; + margin-left: 50%; + transform: translateX(-50%); + object-fit: cover; + object-position: 50% 50%; + } + ion-icon { + margin: 0 auto; + font-size: 40px; + line-height: 50px; + } +} + +.core-login-site-logo, +.core-login-site-list, +.core-login-site-list-found { + transition-delay: 0s; + visibility: visible; + opacity: 1; + transition: all 0.7s ease-in-out; + max-height: 9999px; + + &.hidden { + opacity: 0; + visibility: hidden; + margin-top: 0; + margin-bottom: 0; + padding: 0; + max-height: 0; + } +} + +.core-login-site-list-found.dimmed { + pointer-events: none; + position: relative; +} + +.core-login-site-list-loading { + position: absolute; + //@todo @include position(0, 0, 0, 0); + width: 100%; + height: 100%; + display: flex; + align-content: center; + align-items: center; + background-color: rgba(255, 255, 255, 0.5); + z-index: 1; + ion-spinner { + flex: 1; + } +} + +.core-login-site-nolist-loading { + text-align: center; +} + +.item.core-login-site-list-title { + ion-label, ion-label h2.item-heading { + margin-top: 0; + } +} +/* @todo +@include media-breakpoint-up(md) { + .scroll-content > * { + max-width: 600px; + margin: 0 auto; + width: 100%; + } + .core-login-site-logo { + margin-top: 20%; + } + + &.hidden { + margin: 0; + } +} +*/ +.core-login-entered-site { + background-color: gray; // @todo $gray-lighter; + ion-thumbnail { + box-shadow: 0 0 4px #ddd; + } +} + + +.core-login-default-icon { + filter: grayscale(100%); } diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts new file mode 100644 index 000000000..c37c2807c --- /dev/null +++ b/src/app/core/login/services/helper.ts @@ -0,0 +1,1327 @@ +// (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 { Location } from '@angular/common'; +import { Router, Params } from '@angular/router'; +import { NavController } from '@ionic/angular'; +import { Md5 } from 'ts-md5/dist/md5'; + +import { CoreApp, CoreStoreConfig } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreEvents, CoreEventSessionExpiredData, CoreEventsProvider } from '@services/events'; +import { CoreSites, CoreLoginSiteInfo } from '@services/sites'; +import { CoreWS, CoreWSExternalWarning } from '@services/ws'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlParams, CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import CoreConfigConstants from '@app/config.json'; +import { CoreConstants } from '@core/constants'; +import { CoreSite, CoreSiteConfig, CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes/site'; +import { CoreError } from '@classes/errors/error'; +import { CoreWSError } from '@classes/errors/wserror'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreUrl } from '@singletons/url'; + +/** + * Helper provider that provides some common features regarding authentication. + */ +@Injectable() +export class CoreLoginHelperProvider { + + static readonly OPEN_COURSE = 'open_course'; + static readonly ONBOARDING_DONE = 'onboarding_done'; + static readonly FAQ_URL_IMAGE_HTML = ''; + static readonly FAQ_QRCODE_IMAGE_HTML = ''; + + protected logger: CoreLogger; + protected isSSOConfirmShown = false; + protected isOpenEditAlertShown = false; + protected pageToLoad: {page: string; params: Params; time: number}; // Page to load once main menu is opened. + protected isOpeningReconnect = false; + waitingForBrowser = false; + + constructor( + protected router: Router, + private location: Location, + ) { + this.logger = CoreLogger.getInstance('CoreLoginHelper'); + + CoreEvents.instance.on(CoreEventsProvider.MAIN_MENU_OPEN, () => { + /* If there is any page pending to be opened, do it now. Don't open pages stored more than 5 seconds ago, probably + the function to open the page was called when it shouldn't. */ + if (this.pageToLoad && Date.now() - this.pageToLoad.time < 5000) { + this.loadPageInMainMenu(this.pageToLoad.page, this.pageToLoad.params); + delete this.pageToLoad; + } + }); + } + + /** + * Accept site policy. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected if failure. + */ + async acceptSitePolicy(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const result = await site.write('core_user_agree_site_policy', {}); + + if (!result.status) { + // Error. + if (result.warnings && result.warnings.length) { + // Check if there is a warning 'alreadyagreed'. + for (const i in result.warnings) { + const warning = result.warnings[i]; + if (warning.warningcode == 'alreadyagreed') { + // Policy already agreed, treat it as a success. + return; + } + } + + // Another warning, reject. + throw new CoreWSError(result.warnings[0]); + } else { + throw new CoreError('Cannot agree site policy'); + } + } + } + + /** + * Check if a site allows requesting a password reset through the app. + * + * @param siteUrl URL of the site. + * @return Promise resolved with boolean: whether can be done through the app. + */ + async canRequestPasswordReset(siteUrl: string): Promise { + try { + await this.requestPasswordReset(siteUrl); + + return true; + } catch (error) { + return error.available == 1 || (error.errorcode && error.errorcode != 'invalidrecord'); + } + } + + /** + * Function called when an SSO InAppBrowser is closed or the app is resumed. Check if user needs to be logged out. + */ + checkLogout(): void { + // @todo + } + + /** + * Show a confirm modal if needed and open a browser to perform SSO login. + * + * @param siteurl URL of the site where the SSO login will be performed. + * @param typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. + * @param service The service to use. If not defined, external service will be used. + * @param launchUrl The URL to open for SSO. If not defined, local_mobile launch URL will be used. + * @return Promise resolved when done or if user cancelled. + */ + async confirmAndOpenBrowserForSSOLogin( + siteUrl: string, + typeOfLogin: number, + service?: string, + launchUrl?: string, + ): Promise { + // Show confirm only if it's needed. Treat "false" (string) as false to prevent typing errors. + const showConfirmation = this.shouldShowSSOConfirm(typeOfLogin); + + if (showConfirmation) { + try { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.login.logininsiterequired')); + } catch (error) { + // User canceled, stop. + return; + } + } + + this.openBrowserForSSOLogin(siteUrl, typeOfLogin, service, launchUrl); + } + + /** + * Helper function to act when the forgotten password is clicked. + * + * @param siteUrl Site URL. + * @param username Username. + * @param siteConfig Site config. + */ + async forgottenPasswordClicked( + siteUrl: string, + username: string, + siteConfig?: CoreSitePublicConfigResponse, + ): Promise { + if (siteConfig && siteConfig.forgottenpasswordurl) { + // URL set, open it. + CoreUtils.instance.openInApp(siteConfig.forgottenpasswordurl); + + return; + } + + // Check if password reset can be done through the app. + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const canReset = await this.canRequestPasswordReset(siteUrl); + + if (canReset) { + await this.router.navigate(['/login/forgottenpassword'], { + queryParams: { + siteUrl, + username, + }, + }); + } else { + this.openForgottenPassword(siteUrl); + } + } finally { + modal.dismiss(); + } + } + + /** + * Format profile fields, filtering the ones that shouldn't be shown on signup and classifying them in categories. + * + * @param profileFields Profile fields to format. + * @return Categories with the fields to show in each one. + */ + formatProfileFieldsForSignup(profileFields: AuthEmailSignupProfileField[]): AuthEmailSignupProfileFieldsCategory[] { + if (!profileFields) { + return []; + } + + const categories: Record = {}; + + profileFields.forEach((field) => { + if (!field.signup) { + // Not a signup field, ignore it. + return; + } + + if (!categories[field.categoryid]) { + categories[field.categoryid] = { + id: field.categoryid, + name: field.categoryname, + fields: [], + }; + } + + categories[field.categoryid].fields.push(field); + }); + + return Object.keys(categories).map((index) => categories[Number(index)]); + } + + /** + * Get disabled features from a site public config. + * + * @param config Site public config. + * @return Disabled features. + */ + getDisabledFeatures(config: CoreSitePublicConfigResponse): string { + const disabledFeatures = config && config.tool_mobile_disabledfeatures; + if (!disabledFeatures) { + return ''; + } + + return CoreTextUtils.instance.treatDisabledFeatures(disabledFeatures); + } + + /** + * Builds an object with error messages for some common errors. + * Please notice that this function doesn't support all possible error types. + * + * @param requiredMsg Code of the string for required error. + * @param emailMsg Code of the string for invalid email error. + * @param patternMsg Code of the string for pattern not match error. + * @param urlMsg Code of the string for invalid url error. + * @param minlengthMsg Code of the string for "too short" error. + * @param maxlengthMsg Code of the string for "too long" error. + * @param minMsg Code of the string for min value error. + * @param maxMsg Code of the string for max value error. + * @return Object with the errors. + */ + getErrorMessages( + requiredMsg?: string, + emailMsg?: string, + patternMsg?: string, + urlMsg?: string, + minlengthMsg?: string, + maxlengthMsg?: string, + minMsg?: string, + maxMsg?: string, + ): any { + const errors: any = {}; + + if (requiredMsg) { + errors.required = errors.requiredTrue = Translate.instance.instant(requiredMsg); + } + if (emailMsg) { + errors.email = Translate.instance.instant(emailMsg); + } + if (patternMsg) { + errors.pattern = Translate.instance.instant(patternMsg); + } + if (urlMsg) { + errors.url = Translate.instance.instant(urlMsg); + } + if (minlengthMsg) { + errors.minlength = Translate.instance.instant(minlengthMsg); + } + if (maxlengthMsg) { + errors.maxlength = Translate.instance.instant(maxlengthMsg); + } + if (minMsg) { + errors.min = Translate.instance.instant(minMsg); + } + if (maxMsg) { + errors.max = Translate.instance.instant(maxMsg); + } + + return errors; + } + + /** + * Get logo URL from a site public config. + * + * @param config Site public config. + * @return Logo URL. + */ + getLogoUrl(config: CoreSitePublicConfigResponse): string { + return !CoreConfigConstants.forceLoginLogo && config ? (config.logourl || config.compactlogourl) : null; + } + + /** + * Returns the logout label of a site. + * + * @param site Site. If not defined, use current site. + * @return The string key. + */ + getLogoutLabel(site?: CoreSite): string { + site = site || CoreSites.instance.getCurrentSite(); + const config = site.getStoredConfig(); + + return 'core.mainmenu.' + (config && config.tool_mobile_forcelogout == '1' ? 'logout' : 'changesite'); + } + + /** + * Get the OAuth ID of some URL params (if it has an OAuth ID). + * + * @param params Params. + * @return OAuth ID. + */ + getOAuthIdFromParams(params: CoreUrlParams): number { + return params && typeof params.oauthsso != 'undefined' ? Number(params.oauthsso) : undefined; + } + + /** + * Get the site policy. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the site policy. + */ + async getSitePolicy(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + let sitePolicy: string; + + try { + // Try to get the latest config, maybe the site policy was just added or has changed. + sitePolicy = await site.getConfig('sitepolicy', true); + } catch (error) { + // Cannot get config, try to get the site policy using auth_email_get_signup_settings. + const settings = await CoreWS.instance.callAjax('auth_email_get_signup_settings', {}, + { siteUrl: site.getURL() }); + + sitePolicy = settings.sitepolicy; + } + + if (!sitePolicy) { + throw new CoreError('Cannot retrieve site policy'); + } + + return sitePolicy; + } + + /** + * Get fixed site or sites. + * + * @return Fixed site or list of fixed sites. + */ + getFixedSites(): string | CoreLoginSiteInfo[] { + return CoreConfigConstants.siteurl; + } + + /** + * Get the valid identity providers from a site config. + * + * @param siteConfig Site's public config. + * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. + * @return Valid identity providers. + */ + getValidIdentityProviders(siteConfig: CoreSitePublicConfigResponse, disabledFeatures?: string): CoreSiteIdentityProvider[] { + if (this.isFeatureDisabled('NoDelegate_IdentityProviders', siteConfig, disabledFeatures)) { + // Identity providers are disabled, return an empty list. + return []; + } + + const validProviders: CoreSiteIdentityProvider[] = []; + const httpUrl = CoreTextUtils.instance.concatenatePaths(siteConfig.wwwroot, 'auth/oauth2/'); + const httpsUrl = CoreTextUtils.instance.concatenatePaths(siteConfig.httpswwwroot, 'auth/oauth2/'); + + if (siteConfig.identityproviders && siteConfig.identityproviders.length) { + siteConfig.identityproviders.forEach((provider) => { + const urlParams = CoreUrlUtils.instance.extractUrlParams(provider.url); + + if (provider.url && (provider.url.indexOf(httpsUrl) != -1 || provider.url.indexOf(httpUrl) != -1) && + !this.isFeatureDisabled('NoDelegate_IdentityProvider_' + urlParams.id, siteConfig, disabledFeatures)) { + validProviders.push(provider); + } + }); + } + + return validProviders; + } + + /** + * Go to the page to add a new site. + * If a fixed URL is configured, go to credentials instead. + * + * @param setRoot True to set the new page as root, false to add it to the stack. + * @param showKeyboard Whether to show keyboard in the new page. Only if no fixed URL set. + * @return Promise resolved when done. + */ + async goToAddSite(setRoot?: boolean, showKeyboard?: boolean): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Open a page that doesn't belong to any site. + * + * @param navCtrl Nav Controller. + * @param page Page to open. + * @param params Params of the page. + * @return Promise resolved when done. + */ + goToNoSitePage(navCtrl: NavController, page: string, params?: Params): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Go to the initial page of a site depending on 'userhomepage' setting. + * + * @param navCtrl NavController to use. Defaults to app root NavController. + * @param page Name of the page to load after loading the main page. + * @param params Params to pass to the page. + * @param options Navigation options. + * @param url URL to open once the main menu is loaded. + * @return Promise resolved when done. + */ + goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: Params, options?: any, url?: string): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Convenient helper to handle authentication in the app using a token received by SSO login. If it's a new account, + * the site is stored and the user is authenticated. If the account already exists, update its token. + * + * @param siteUrl Site's URL. + * @param token User's token. + * @param privateToken User's private token. + * @param oauthId OAuth ID. Only if the authentication was using an OAuth method. + * @return Promise resolved when the user is authenticated with the token. + */ + handleSSOLoginAuthentication(siteUrl: string, token: string, privateToken?: string, oauthId?: number): Promise { + // Always create a new site to prevent overriding data if another user credentials were introduced. + return CoreSites.instance.newSite(siteUrl, token, privateToken, true, oauthId); + } + + /** + * Check if the app is configured to use several fixed URLs. + * + * @return Whether there are several fixed URLs. + */ + hasSeveralFixedSites(): boolean { + return CoreConfigConstants.siteurl && Array.isArray(CoreConfigConstants.siteurl) && + CoreConfigConstants.siteurl.length > 1; + } + + /** + * Given a site public config, check if email signup is disabled. + * + * @param config Site public config. + * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. + * @return Whether email signup is disabled. + */ + isEmailSignupDisabled(config?: CoreSitePublicConfigResponse, disabledFeatures?: string): boolean { + return this.isFeatureDisabled('CoreLoginEmailSignup', config, disabledFeatures); + } + + /** + * Given a site public config, check if a certian feature is disabled. + * + * @param feature Feature to check. + * @param config Site public config. + * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. + * @return Whether email signup is disabled. + */ + isFeatureDisabled(feature: string, config?: CoreSitePublicConfigResponse, disabledFeatures?: string): boolean { + if (typeof disabledFeatures == 'undefined') { + disabledFeatures = this.getDisabledFeatures(config); + } + + const regEx = new RegExp('(,|^)' + feature + '(,|$)', 'g'); + + return !!disabledFeatures.match(regEx); + } + + /** + * Check if the app is configured to use a fixed URL (only 1). + * + * @return Whether there is 1 fixed URL. + */ + isFixedUrlSet(): boolean { + if (Array.isArray(CoreConfigConstants.siteurl)) { + return CoreConfigConstants.siteurl.length == 1; + } + + return !!CoreConfigConstants.siteurl; + } + + /** + * Given a site public config, check if forgotten password is disabled. + * + * @param config Site public config. + * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. + * @return Whether it's disabled. + */ + isForgottenPasswordDisabled(config?: CoreSitePublicConfigResponse, disabledFeatures?: string): boolean { + return this.isFeatureDisabled('NoDelegate_ForgottenPassword', config, disabledFeatures); + } + + /** + * Check if current site is logged out, triggering mmCoreEventSessionExpired if it is. + * + * @param pageName Name of the page to go once authenticated if logged out. If not defined, site initial page. + * @param params Params of the page to go once authenticated if logged out. + * @return True if user is logged out, false otherwise. + */ + isSiteLoggedOut(pageName?: string, params?: Params): boolean { + const site = CoreSites.instance.getCurrentSite(); + if (!site) { + return false; + } + + if (site.isLoggedOut()) { + CoreEvents.instance.trigger(CoreEventsProvider.SESSION_EXPIRED, { + pageName, + params, + }, site.getId()); + + return true; + } + + return false; + } + + /** + * Check if a site URL is "allowed". In case the app has fixed sites, only those will be allowed to connect to. + * + * @param siteUrl Site URL to check. + * @return Promise resolved with boolean: whether is one of the fixed sites. + */ + async isSiteUrlAllowed(siteUrl: string): Promise { + if (this.isFixedUrlSet()) { + // Only 1 site allowed. + return CoreUrl.sameDomainAndPath(siteUrl, this.getFixedSites()); + } else if (this.hasSeveralFixedSites()) { + const sites = this.getFixedSites(); + + return sites.some((site) => CoreUrl.sameDomainAndPath(siteUrl, site.url)); + } else if (CoreConfigConstants.multisitesdisplay == 'sitefinder' && CoreConfigConstants.onlyallowlistedsites) { + // Call the sites finder to validate the site. + const result = await CoreSites.instance.findSites(siteUrl.replace(/^https?:\/\/|\.\w{2,3}\/?$/g, '')); + + return result && result.some((site) => CoreUrl.sameDomainAndPath(siteUrl, site.url)); + } else { + // No fixed sites or it uses a non-restrictive sites finder. Allow connecting. + return true; + } + } + + /** + * Check if SSO login should use an embedded browser. + * + * @param code Code to check. + * @return True if embedded browser, false othwerise. + */ + isSSOEmbeddedBrowser(code: number): boolean { + if (CoreApp.instance.isLinux()) { + // In Linux desktop app, always use embedded browser. + return true; + } + + return code == CoreConstants.LOGIN_SSO_INAPP_CODE; + } + + /** + * Check if SSO login is needed based on code returned by the WS. + * + * @param code Code to check. + * @return True if SSO login is needed, false othwerise. + */ + isSSOLoginNeeded(code: number): boolean { + return code == CoreConstants.LOGIN_SSO_CODE || code == CoreConstants.LOGIN_SSO_INAPP_CODE; + } + + /** + * Load a site and load a certain page in that site. + * + * @param page Name of the page to load. + * @param params Params to pass to the page. + * @param siteId Site to load. + * @return Promise resolved when done. + */ + protected loadSiteAndPage(page: string, params: Params, siteId: string): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Load a certain page in the main menu page. + * + * @param page Name of the page to load. + * @param params Params to pass to the page. + */ + loadPageInMainMenu(page: string, params: Params): void { + if (!CoreApp.instance.isMainMenuOpen()) { + // Main menu not open. Store the page to be loaded later. + this.pageToLoad = { + page: page, + params: params, + time: Date.now(), + }; + + return; + } + + if (page == CoreLoginHelperProvider.OPEN_COURSE) { + // @todo Use the openCourse function. + } else { + CoreEvents.instance.trigger(CoreEventsProvider.LOAD_PAGE_MAIN_MENU, { redirectPage: page, redirectParams: params }); + } + } + + /** + * Open the main menu, loading a certain page. + * + * @param navCtrl NavController. + * @param page Name of the page to load. + * @param params Params to pass to the page. + * @param options Navigation options. + * @param url URL to open once the main menu is loaded. + * @return Promise resolved when done. + */ + protected openMainMenu(navCtrl: NavController, page: string, params: Params, options?: any, url?: string): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Open a browser to perform OAuth login (Google, Facebook, Microsoft). + * + * @param siteUrl URL of the site where the login will be performed. + * @param provider The identity provider. + * @param launchUrl The URL to open for SSO. If not defined, tool/mobile launch URL will be used. + * @param pageName Name of the page to go once authenticated. If not defined, site initial page. + * @param pageParams Params of the state to go once authenticated. + * @return True if success, false if error. + */ + openBrowserForOAuthLogin( + siteUrl: string, + provider: CoreSiteIdentityProvider, + launchUrl?: string, + pageName?: string, + pageParams?: Params, + ): boolean { + launchUrl = launchUrl || siteUrl + '/admin/tool/mobile/launch.php'; + if (!provider || !provider.url) { + return false; + } + + const params = CoreUrlUtils.instance.extractUrlParams(provider.url); + + if (!params.id) { + return false; + } + + const service = CoreSites.instance.determineService(siteUrl); + const loginUrl = this.prepareForSSOLogin(siteUrl, service, launchUrl, pageName, pageParams, { + oauthsso: params.id, + }); + + if (CoreApp.instance.isLinux()) { + // In Linux desktop app, always use embedded browser. + CoreUtils.instance.openInApp(loginUrl); + } else { + // Always open it in browser because the user might have the session stored in there. + CoreUtils.instance.openInBrowser(loginUrl); + + const nav = window.navigator; // eslint-disable-line @typescript-eslint/no-explicit-any + nav.app?.exitApp(); + } + + return true; + } + + /** + * Open a browser to perform SSO login. + * + * @param siteurl URL of the site where the SSO login will be performed. + * @param typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. + * @param service The service to use. If not defined, external service will be used. + * @param launchUrl The URL to open for SSO. If not defined, local_mobile launch URL will be used. + * @param pageName Name of the page to go once authenticated. If not defined, site initial page. + * @param pageParams Params of the state to go once authenticated. + */ + openBrowserForSSOLogin( + siteUrl: string, + typeOfLogin: number, + service?: string, + launchUrl?: string, + pageName?: string, + pageParams?: Params, + ): void { + const loginUrl = this.prepareForSSOLogin(siteUrl, service, launchUrl, pageName, pageParams); + + if (this.isSSOEmbeddedBrowser(typeOfLogin)) { + CoreUtils.instance.openInApp(loginUrl, { + clearsessioncache: 'yes', // Clear the session cache to allow for multiple logins. + closebuttoncaption: Translate.instance.instant('core.login.cancel'), + }); + } else { + CoreUtils.instance.openInBrowser(loginUrl); + + const nav = window.navigator; // eslint-disable-line @typescript-eslint/no-explicit-any + nav.app?.exitApp(); + } + } + + /** + * Convenient helper to open change password page. + * + * @param siteUrl Site URL to construct change password URL. + * @param error Error message. + * @return Promise resolved when done. + */ + async openChangePassword(siteUrl: string, error: string): Promise { + const alert = await CoreDomUtils.instance.showAlert(Translate.instance.instant('core.notice'), error, undefined, 3000); + + await alert.onDidDismiss(); + + CoreUtils.instance.openInApp(siteUrl + '/login/change_password.php'); + } + + /** + * Open forgotten password in inappbrowser. + * + * @param siteUrl URL of the site. + */ + openForgottenPassword(siteUrl: string): void { + CoreUtils.instance.openInApp(siteUrl + '/login/forgot_password.php'); + } + + /** + * Function to open in app browser to change password or complete user profile. + * + * @param siteId The site ID. + * @param path The relative path of the URL to open. + * @param alertMessage The key of the message to display before opening the in app browser. + * @param invalidateCache Whether to invalidate site's cache (e.g. when the user is forced to change password). + * @return Promise resolved when done. + */ + async openInAppForEdit(siteId: string, path: string, alertMessage?: string, invalidateCache?: boolean): Promise { + if (!siteId || siteId !== CoreSites.instance.getCurrentSiteId()) { + // Site that triggered the event is not current site, nothing to do. + return; + } + + const currentSite = CoreSites.instance.getCurrentSite(); + const siteUrl = currentSite?.getURL(); + + if (!currentSite || !siteUrl) { + return; + } + + if (!this.isOpenEditAlertShown && !this.waitingForBrowser) { + this.isOpenEditAlertShown = true; + + if (invalidateCache) { + currentSite.invalidateWsCache(); + } + + // Open change password. + if (alertMessage) { + alertMessage = Translate.instance.instant(alertMessage) + '
' + + Translate.instance.instant('core.redirectingtosite'); + } + + try { + await currentSite.openInAppWithAutoLogin(siteUrl + path, undefined, alertMessage); + + this.waitingForBrowser = true; + } finally { + this.isOpenEditAlertShown = false; + } + } + } + + /** + * Function that should be called when password change is forced. Reserved for core use. + * + * @param siteId The site ID. + */ + passwordChangeForced(siteId: string): void { + // @todo + } + + /** + * Prepare the app to perform SSO login. + * + * @param siteUrl URL of the site where the SSO login will be performed. + * @param service The service to use. If not defined, external service will be used. + * @param launchUrl The URL to open for SSO. If not defined, local_mobile launch URL will be used. + * @param pageName Name of the page to go once authenticated. If not defined, site initial page. + * @param pageParams Params of the state to go once authenticated. + * @param urlParams Other params to add to the URL. + * @return Login Url. + */ + prepareForSSOLogin( + siteUrl: string, + service?: string, + launchUrl?: string, + pageName?: string, + pageParams?: Params, + urlParams?: CoreUrlParams, + ): string { + + service = service || CoreConfigConstants.wsextservice; + launchUrl = launchUrl || siteUrl + '/local/mobile/launch.php'; + + const passport = Math.random() * 1000; + let loginUrl = launchUrl + '?service=' + service; + + loginUrl += '&passport=' + passport; + loginUrl += '&urlscheme=' + CoreConfigConstants.customurlscheme; + + if (urlParams) { + loginUrl = CoreUrlUtils.instance.addParamsToUrl(loginUrl, urlParams); + } + + // Store the siteurl and passport in CoreConfigProvider for persistence. + // We are "configuring" the app to wait for an SSO. CoreConfigProvider shouldn't be used as a temporary storage. + CoreConfig.instance.set(CoreConstants.LOGIN_LAUNCH_DATA, JSON.stringify( { + siteUrl: siteUrl, + passport: passport, + pageName: pageName || '', + pageParams: pageParams || {}, + ssoUrlParams: urlParams || {}, + })); + + return loginUrl; + } + + /** + * Redirect to a new page, setting it as the root page and loading the right site if needed. + * + * @param page Name of the page to load. Special cases: OPEN_COURSE (to open course page). + * @param params Params to pass to the page. + * @param siteId Site to load. If not defined, current site. + * @return Promise resolved when done. + */ + async redirect(page: string, params?: Params, siteId?: string): Promise { + // @todo + } + + /** + * Request a password reset. + * + * @param siteUrl URL of the site. + * @param username Username to search. + * @param email Email to search. + * @return Promise resolved when done. + */ + requestPasswordReset(siteUrl: string, username?: string, email?: string): Promise { + const params: Record = {}; + + if (username) { + params.username = username; + } + + if (email) { + params.email = email; + } + + return CoreWS.instance.callAjax('core_auth_request_password_reset', params, { siteUrl }); + } + + /** + * Function that should be called when the session expires. Reserved for core use. + * + * @param data Data received by the SESSION_EXPIRED event. + * @return Promise resolved when done. + */ + async sessionExpired(data: CoreEventSessionExpiredData): Promise { + const siteId = data?.siteId; + const currentSite = CoreSites.instance.getCurrentSite(); + const siteUrl = currentSite?.getURL(); + + if (!currentSite || !siteUrl) { + return; + } + + if (siteId && siteId !== currentSite.getId()) { + return; // Site that triggered the event is not current site. + } + + try { + // Check authentication method. + const result = await CoreSites.instance.checkSite(siteUrl); + + if (result.warning) { + CoreDomUtils.instance.showErrorModal(result.warning, true, 4000); + } + + if (this.isSSOLoginNeeded(result.code)) { + // SSO. User needs to authenticate in a browser. Check if we need to display a message. + if (!CoreApp.instance.isSSOAuthenticationOngoing() && !this.isSSOConfirmShown && !this.waitingForBrowser) { + this.isSSOConfirmShown = true; + + if (this.shouldShowSSOConfirm(result.code)) { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.login.' + + (currentSite.isLoggedOut() ? 'loggedoutssodescription' : 'reconnectssodescription'))); + } + + try { + this.waitingForBrowser = true; + + this.openBrowserForSSOLogin(result.siteUrl, result.code, result.service, result.config?.launchurl, + data.pageName, data.params); + } catch (error) { + // User cancelled, logout him. + CoreSites.instance.logout(); + } finally { + this.isSSOConfirmShown = false; + } + } + } else { + if (currentSite.isOAuth()) { + // User authenticated using an OAuth method. Check if it's still valid. + const identityProviders = this.getValidIdentityProviders(result.config); + const providerToUse = identityProviders.find((provider) => { + const params = CoreUrlUtils.instance.extractUrlParams(provider.url); + + return Number(params.id) == currentSite.getOAuthId(); + }); + + if (providerToUse) { + if (!CoreApp.instance.isSSOAuthenticationOngoing() && !this.isSSOConfirmShown && !this.waitingForBrowser) { + // Open browser to perform the OAuth. + this.isSSOConfirmShown = true; + + const confirmMessage = Translate.instance.instant('core.login.' + + (currentSite.isLoggedOut() ? 'loggedoutssodescription' : 'reconnectssodescription')); + + try { + await CoreDomUtils.instance.showConfirm(confirmMessage); + + this.waitingForBrowser = true; + CoreSites.instance.unsetCurrentSite(); // Unset current site to make authentication work fine. + + this.openBrowserForOAuthLogin(siteUrl, providerToUse, result.config.launchurl, data.pageName, + data.params); + } catch (error) { + // User cancelled, logout him. + CoreSites.instance.logout(); + } finally { + this.isSSOConfirmShown = false; + } + } + + return; + } + } + + const info = currentSite.getInfo(); + if (typeof info != 'undefined' && typeof info.username != 'undefined' && !this.isOpeningReconnect) { + // @todo + } + } + } catch (error) { + // Error checking site. + if (currentSite.isLoggedOut()) { + // Site is logged out, show error and logout the user. + CoreDomUtils.instance.showErrorModalDefault(error, 'core.networkerrormsg', true); + CoreSites.instance.logout(); + } + } + } + + /** + * Check if a confirm should be shown to open a SSO authentication. + * + * @param typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. + * @return True if confirm modal should be shown, false otherwise. + */ + shouldShowSSOConfirm(typeOfLogin: number): boolean { + return !this.isSSOEmbeddedBrowser(typeOfLogin) && + (!CoreConfigConstants.skipssoconfirmation || String(CoreConfigConstants.skipssoconfirmation) === 'false'); + } + + /** + * Show a modal warning the user that he should use the Workplace app. + * + * @param message The warning message. + */ + protected showWorkplaceNoticeModal(message: string): void { + const link = CoreApp.instance.getAppStoreUrl({ android: 'com.moodle.workplace', ios: 'id1470929705' }); + + CoreDomUtils.instance.showDownloadAppNoticeModal(message, link); + } + + /** + * Show a modal warning the user that he should use the current Moodle app. + * + * @param message The warning message. + */ + protected showMoodleAppNoticeModal(message: string): void { + const storesConfig: CoreStoreConfig = CoreConfigConstants.appstores; + storesConfig.desktop = 'https://download.moodle.org/desktop/'; + storesConfig.mobile = 'https://download.moodle.org/mobile/'; + storesConfig.default = 'https://download.moodle.org/mobile/'; + + const link = CoreApp.instance.getAppStoreUrl(storesConfig); + + CoreDomUtils.instance.showDownloadAppNoticeModal(message, link); + } + + /** + * Show a modal to inform the user that a confirmation email was sent, and a button to resend the email on 3.6+ sites. + * + * @param siteUrl Site URL. + * @param email Email of the user. If set displayed in the message. + * @param username Username. If not set the button to resend email will not be shown. + * @param password User password. If not set the button to resend email will not be shown. + */ + protected async showNotConfirmedModal(siteUrl: string, email?: string, username?: string, password?: string): Promise { + const title = Translate.instance.instant('core.login.mustconfirm'); + let message: string; + let canResend = false; + if (email) { + message = Translate.instance.instant('core.login.emailconfirmsent', { $a: email }); + } else { + message = Translate.instance.instant('core.login.emailconfirmsentnoemail'); + } + + // Check whether we need to display the resend button or not. + if (username && password) { + canResend = await this.canResendEmail(siteUrl); + } + + if (!canResend) { + // Just display an informative alert. + await CoreDomUtils.instance.showAlert(title, message); + + return; + } + + const okText = Translate.instance.instant('core.login.resendemail'); + const cancelText = Translate.instance.instant('core.close'); + + try { + // Ask the user if he wants to resend the email. + await CoreDomUtils.instance.showConfirm(message, title, okText, cancelText); + + // Call the WS to resend the confirmation email. + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + const data = { username, password }; + const preSets = { siteUrl }; + + try { + const result = await CoreWS.instance.callAjax( + 'core_auth_resend_confirmation_email', data, preSets); + + if (!result.status) { + throw new CoreWSError(result.warnings[0]); + } + + const message = Translate.instance.instant('core.login.emailconfirmsentsuccess'); + CoreDomUtils.instance.showAlert(Translate.instance.instant('core.success'), message); + } finally { + modal.dismiss(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } + } + + /** + * Check if confirmation email an be resent. + * + * @param siteUrl Site URL to check. + * @return Promise. + */ + protected async canResendEmail(siteUrl: string): Promise { + const modal = await CoreDomUtils.instance.showModalLoading(); + + // We don't have site info before login, the only way to check if the WS is available is by calling it. + try { + // This call will always fail because we aren't sending parameters. + await CoreWS.instance.callAjax('core_auth_resend_confirmation_email', {}, { siteUrl }); + } catch (error) { + // If the WS responds with an invalid parameter error it means the WS is avaiable. + return error?.errorcode === 'invalidparameter'; + } finally { + modal.dismiss(); + } + } + + /** + * Function called when site policy is not agreed. Reserved for core use. + * + * @param siteId Site ID. If not defined, current site. + */ + sitePolicyNotAgreed(siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + if (!siteId || siteId != CoreSites.instance.getCurrentSiteId()) { + // Only current site allowed. + return; + } + + if (!CoreSites.instance.wsAvailableInCurrentSite('core_user_agree_site_policy')) { + // WS not available, stop. + return; + } + + // @todo Navigate to site policy page. + } + + /** + * Convenient helper to handle get User Token error. It redirects to change password page if forcepassword is set. + * + * @param siteUrl Site URL to construct change password URL. + * @param error Error object containing errorcode and error message. + * @param username Username. + * @param password User password. + */ + treatUserTokenError(siteUrl: string, error: CoreWSError, username?: string, password?: string): void { + if (error.errorcode == 'forcepasswordchangenotice') { + this.openChangePassword(siteUrl, CoreTextUtils.instance.getErrorMessageFromError(error)); + } else if (error.errorcode == 'usernotconfirmed') { + this.showNotConfirmedModal(siteUrl, undefined, username, password); + } else if (error.errorcode == 'connecttomoodleapp') { + this.showMoodleAppNoticeModal(CoreTextUtils.instance.getErrorMessageFromError(error)); + } else if (error.errorcode == 'connecttoworkplaceapp') { + this.showWorkplaceNoticeModal(CoreTextUtils.instance.getErrorMessageFromError(error)); + } else { + CoreDomUtils.instance.showErrorModal(error); + } + } + + /** + * Convenient helper to validate a browser SSO login. + * + * @param url URL received, to be validated. + * @return Promise resolved on success. + */ + async validateBrowserSSOLogin(url: string): Promise { + // Split signature:::token + const params = url.split(':::'); + + const serializedData = await CoreConfig.instance.get(CoreConstants.LOGIN_LAUNCH_DATA); + + const data = CoreTextUtils.instance.parseJSON(serializedData, null); + if (data === null) { + throw new CoreError('No launch data stored.'); + } + + const passport = data.passport; + let launchSiteURL = data.siteUrl; + + // Reset temporary values. + CoreConfig.instance.delete(CoreConstants.LOGIN_LAUNCH_DATA); + + // Validate the signature. + // We need to check both http and https. + let signature = Md5.hashAsciiStr(launchSiteURL + passport); + if (signature != params[0]) { + if (launchSiteURL.indexOf('https://') != -1) { + launchSiteURL = launchSiteURL.replace('https://', 'http://'); + } else { + launchSiteURL = launchSiteURL.replace('http://', 'https://'); + } + signature = Md5.hashAsciiStr(launchSiteURL + passport); + } + + if (signature == params[0]) { + this.logger.debug('Signature validated'); + + return { + siteUrl: launchSiteURL, + token: params[1], + privateToken: params[2], + pageName: data.pageName, + pageParams: data.pageParams, + ssoUrlParams: data.ssoUrlParams, + }; + } else { + this.logger.debug('Invalid signature in the URL request yours: ' + params[0] + ' mine: ' + + signature + ' for passport ' + passport); + + throw new CoreError(Translate.instance.instant('core.unexpectederror')); + } + } + +} + +export class CoreLoginHelper extends makeSingleton(CoreLoginHelperProvider) {} + +/** + * Data related to a SSO authentication. + */ +export interface CoreLoginSSOData { + /** + * The site's URL. + */ + siteUrl: string; + + /** + * User's token. + */ + token?: string; + + /** + * User's private token. + */ + privateToken?: string; + + /** + * Name of the page to go after authenticated. + */ + pageName?: string; + + /** + * Params to page to the page. + */ + pageParams?: Params; + + /** + * Other params added to the login url. + */ + ssoUrlParams?: CoreUrlParams; +}; + +/** + * Result of WS core_user_agree_site_policy. + */ +type AgreeSitePolicyResult = { + status: boolean; // Status: true only if we set the policyagreed to 1 for the user. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of WS auth_email_get_signup_settings. + */ +export type AuthEmailSignupSettings = { + namefields: string[]; + passwordpolicy?: string; // Password policy. + sitepolicy?: string; // Site policy. + sitepolicyhandler?: string; // Site policy handler. + defaultcity?: string; // Default city. + country?: string; // Default country. + profilefields?: AuthEmailSignupProfileField[]; // Required profile fields. + recaptchapublickey?: string; // Recaptcha public key. + recaptchachallengehash?: string; // Recaptcha challenge hash. + recaptchachallengeimage?: string; // Recaptcha challenge noscript image. + recaptchachallengejs?: string; // Recaptcha challenge js url. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Profile field for signup. + */ +export type AuthEmailSignupProfileField = { + id?: number; // Profile field id. + shortname?: string; // Profile field shortname. + name?: string; // Profield field name. + datatype?: string; // Profield field datatype. + description?: string; // Profield field description. + descriptionformat: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + categoryid?: number; // Profield field category id. + categoryname?: string; // Profield field category name. + sortorder?: number; // Profield field sort order. + required?: number; // Profield field required. + locked?: number; // Profield field locked. + visible?: number; // Profield field visible. + forceunique?: number; // Profield field unique. + signup?: number; // Profield field in signup form. + defaultdata?: string; // Profield field default data. + defaultdataformat: number; // Defaultdata format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + param1?: string; // Profield field settings. + param2?: string; // Profield field settings. + param3?: string; // Profield field settings. + param4?: string; // Profield field settings. + param5?: string; // Profield field settings. +}; + +/** + * Category of profile fields for signup. + */ +export type AuthEmailSignupProfileFieldsCategory = { + id: number; // Category ID. + name: string; // Category name. + fields: AuthEmailSignupProfileField[]; // Field in the category. +}; + +/** + * Result of WS core_auth_request_password_reset. + */ +export type CoreLoginRequestPasswordResetResult = { + status: string; // The returned status of the process + notice: string; // Important information for the user about the process. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of WS core_auth_resend_confirmation_email. + */ +type ResendConfirmationEmailResult = { + status: boolean; // True if the confirmation email was sent, false otherwise. + warnings?: CoreWSExternalWarning[]; +}; + +type StoredLoginLaunchData = { + siteUrl: string; + passport: number; + pageName: string; + pageParams: Params; + ssoUrlParams: CoreUrlParams; +}; diff --git a/src/app/services/events.ts b/src/app/services/events.ts index 528aec327..505379a90 100644 --- a/src/app/services/events.ts +++ b/src/app/services/events.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; import { Subject } from 'rxjs'; import { CoreLogger } from '@singletons/logger'; @@ -199,3 +200,12 @@ export class CoreEventsProvider { } export class CoreEvents extends makeSingleton(CoreEventsProvider) {} + +/** + * Data passed to session expired event. + */ +export type CoreEventSessionExpiredData = { + pageName?: string; + params?: Params; + siteId?: string; +}; diff --git a/src/app/services/sites.ts b/src/app/services/sites.ts index 6efc5d214..004be6ac3 100644 --- a/src/app/services/sites.ts +++ b/src/app/services/sites.ts @@ -276,7 +276,7 @@ export class CoreSitesProvider { * @param name Name of the site to check. * @return Site data if it's a demo site, undefined otherwise. */ - getDemoSiteData(name: string): {[name: string]: CoreSitesDemoSiteData} { + getDemoSiteData(name: string): CoreSitesDemoSiteData { const demoSites = CoreConfigConstants.demo_sites; name = name.toLowerCase(); diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts index e72f72056..97b4e3472 100644 --- a/src/app/services/utils/dom.ts +++ b/src/app/services/utils/dom.ts @@ -1304,6 +1304,11 @@ export class CoreDomUtilsProvider { needsTranslate?: boolean, autocloseTime?: number, ): Promise { + if (this.isCanceledError(error)) { + // It's a canceled error, don't display an error. + return null; + } + const message = this.getErrorMessage(error, needsTranslate); if (message === null) { diff --git a/src/assets/img/login_logo.png b/src/assets/img/login_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0cbb69d0e497af9fb7d6aec5c53e8cbb32af1219 GIT binary patch literal 16563 zcmajH1yGz#^DhdEy9alN;1ZnR?kuhm+=Kh#E&+nO1QvIP#VrJPTL=UR9$e49-~0dW ztvdIfI<@<(bj>`~)7|r%p6;F}T2oyC3!Mxd4h{}WNl{iC4h~=d`<;b~4Eyw05mbhK zA%9R&kcE4N^MHf1q+C>lQP5lz4Zpy8Ed6uAOS5Eq!-y#EN@{W_zmSMAaEWuAjH}_` zXyBA&rF6YlPjY?yO+H`0Zteg4k*n14naP&*G2vKcu80QJsSSFgBZYg5wT_F23u1a_ zyCUnd)eArmbRFUvrz=N|p_xtXW)n@VRTX>B`}j+pmcust+is?k?`ExjPk(yT z?WVQx)V}bP&rdwe0dCBXAq2>iYoJYdhj%{{W8UK^ql7=oKO~m^G+~NqLlzqEvrZ3w zo|Ss(6$BjNOl3j1|LHV<_|1|e0`^9G`_DgaOag;{{{8Azk6 zcR~h)_unp{&;Plj<^P8OnE00o?*F#Y|FZdiqxG3dj5+F4|_+~z!b1K+r zDAueTlP3Ej>N|)pxU#dOL)zZ;KRN^n6lRKgal(Pw^7cO$v8)m;7qgXl!rw#{*$`Wh zarqW~ileH9s6o=r9xp*%Cvbc_G*j9`=&cx~!YZ3nQ>G9V<@%%iIEv0+kO#cXzjhtHZn*0s;aG!e7}>RIWydOL-OXGYI}_vOV=S zGev>qW>$XNIVklQi%W zYVi{-7q+(EHbclk^s=8G5OH+}43XcyO`ni#yWRBTNZR<9|Mooy0#2$d9S_zrUrbRe zTrD%UriKROCNWqPc%8};^Wt&VeY+a}rmw`N_-;~W1$ z7__ST;^t-#vU4VABZM6k!BJwGkMu30?H?S9<4AFBFMa2Ax^7}`|7Qf$3n$5ZVneI{Rj5Ys%H3{H@gnC2y+X-%sf8Mh)&p|6-OFCj~7_upeC3}eiazol?_6YN&M~4 zm6}<=2V$}iaovNbR)kZ|aS$Xt&^bWpkaI`MtXq4%3@$zQ&Xnzo`=r!mrE3+k`~Ew; zPLzEb6F57cS5y0d_(1Le{u}dj+SrZsjL{4?#mXccG6Bb-YW{Tuw ze%;rB!eO@(Ud3A`TE<>}lYzDyOgiv}ytiXHHJA=p@70T_uIQl=-y7Nc2mOUNo576r z25Du9wt{C1m_n2C){i*7t`cP}R8Vj29q?HRmwHI$IuW4D|Hg>1pq!01G_!|$R7@&| z??eh3cyZOEt82grYQ9%Y6=_t{+NFf+;Htx^Ge+~2&Lq19Jpjqm%U`&wsOh4^^+-z* z!-zi7V7jSP2Gk*XzYp`%F*0J3suj3H#q{{0h_?B&{20{%=LdK->u^Bu&EAm)byDq% zSnN4JzW9-XxVYdj^SiyD>rKjo?7{kQqWVEC?EQMt%JYZ}T3nnTxrUcr`@-k=@n032&Hgq(c;bIa!Nd~FJ_M2cQRTrxww9+SN7ZE9-!paaEir( zL>K;^GS7aw896el{Hmr zUnmC}-@a)}+J9f13OzySBK5|qqpts6mt3F7yl~WJj`f7nX1Z+!62(QHJ-O!qKgZ^B z*bqj?@JG9`9I%7*ET)czt3kxwVa3htmL54=NT=|pvTfBnK8ZfcjB_n+DVsc)S*-}& z#U1q&8g$d4vr^kV$>Bkq1ac-i%|x7wY5(yaJOtE}tZ)LFhp$8T;r(!}acn3)DyZt_ zeh+}?EHobq%D!^SJm~T>7G{32v*uFh=00wmlK8|+nlBXm$PYs)+c~vtS)?}XJKa)k zL(eJkTZcFk_~K7jUvjisCjudIml9`mRx3;^}%*SbV3aQrX=Wcdx6$FtRS#vf_5s);lYI1?+#nyN@CIu3_&##&@7KFM~CG|oty*j?bGLNXjbof`C2MhG?V zj`HYvDKv)K!NegG0$yNp7h(LGN4Ey*K@uX%Q1SOosYvGgNaWg4@&~s&Ax2F5qJlr~ zmuF3aUC2MUh(F$HA9cJ9NvU+LCz#b!;s) zNcZhUUKnI$q5WZha{7M0QIbu|p*w?DFF&0nugDK;)AGRUplrk3MZO?8U+Z92+diLd zH!S+9TI~y#K&6m6=13LE8bE*`Kt6jvkTWD}OqmO_J`^mkgn-NBt}ViG5S(eT`4kr{ za$XI$nTwu){VZ2p2RtAkGoZ4mPN}Ft70C?84f8w#d{^_G;d(qa^3h~OKE;q|prtWR_F>-< z;2s!e472pFXjQ9dRod1m<>RVA;csnh6B4R%b5(Tb&QOJrm#+->7kQNpRF-={afa`@ zUP)@In_mS52WCO9^P~U_CGzP4L_Jdt#TKsLhPkN^adm$L`H&TxyVUaGe>2Os_;iaIXSvFKT0GHMf&MPEH9g2zzEN)?C@1-1r6!iqt6&ZeOWN2cY~&imKsA5+@y0@dz)xa(e15mnv**(4 z0~F_uNnBx+?WFHf&co5VWJtzXPO(jpny@^q?B7f*kvlM2DmVqn)xiJO0KkjlHwK>- znUHbXZu*qd>+6#!(%2 zED>|bbgwBYKmA9}$FZWjl(WZ!C1aZ?bbT9qi2@B!eF5V{!YzSrU+Rk6e=9hXy(ihd z0R*a0DDWkJA@N<{aknMeZ5bw!aZTHJvxfrl0&z$|ls#%*3hBBeF^8>cm_VNgId>Rb zxgRYd9|xCCBRh`?{*Vk@apTead8^lu@cvx>?pB~A>aUO^o>}A4B->?z=_P}>=~m4# zM@ko(7*xFcCmDK{WGY%mzWc@W2fr_&MfnJ;m7?D`!o@-63i!EHp1U=wt^4+R=uF3> zOmcf9@iMqpj#2^BfF~SqXC&oH?CeQ+magnHMuRry9&T#B?#R%m?P^Hk`zanj_C{UbP9bLMcT(JtfNr%(LhA}9^1)284sTDd`@IjC)f+n7O~Jv7WBCPJZ1 ztkGU!Y?4Tx8F8z@LcP$OBRb)q&qDW3coDh6IBBf~4+z7rOjg}G$eaZD-yi#8CTn^d zYy5hm@6dlSF>)GpdDr9bXDok$Il$RE3pTS8AIybMf&pt;ZDcJ8xS>aVmVS*IIVxukb}1|EX6M0)g+Ext^Pign zE2lY_MK!4;!*;%G(z3uN6|oR0-VQ(Y@bB^_n(QdLO`L~#j=mKhWydR?(piJ!9|4Qb z6Sb@BXp%W^xymxV+AHVA)^+Ca$15Ih>NIMmYCb185Kd8|4@*dt5`OyrVaHC06{?%` zvyT3c>_M2+PlYRYXby*o3072c~y_^q7FM*sra>yVoYG2 zxwVnCVUS*EQMO+}h2ozw=2PtZR(A1%%ym;UNyYi6EVVkq1>Sbj08Y889w_Z5HRbmB z*QDrgrHr=K0&Jw!*ea?N$Qt)(SyR<_gnWKi0XkM_EIRWapSj^ZHzgAO+V<@Q+a0^^ zmRs>C6B1AUM3qOvwMMK#AdT7QNyo0icDx^ig_l-PV*}Ezct>RoLLq_UA54NCUu)Ux z=1r$%*s2zz#N?{w#pEDT#4^SS%9GLSV$;-ESdN5{ zkD&C>C3pGhe0|5Pz}=C?5@X_97ZKHMkP=Z=;x;Z(>9gU9M4+ENEPAo)veV^|z5fQ+ znQB~aVKSuN(u&0dAGuv&PU>Z|{y}AcrZoj}OY*vAk}~9y4%>qnYw{l4$zd`UuaG&(7JFT%Z+fcS$^du>1aNoeyN*HPqI$S|=(^y9NoQJtr^g-cNs#pAVdBUt(#LW=+%;x&SF<_uz zB2WqnZnIOVE{D6-eMhuJ_ckrn^U<0}-n|93Y^r?Sb4y_{MyN&DRP(<&@ulxZa8ar+ zJ`@l1n>y0MsFJ}EPvyo8pt%&Q-92)Y`=kkE4o9(9*U98MPPc9)eU1{|dzZe1duRe} zQ6tcwWYrSS6(X;#k9Mq)wYHh&HxJ31l&CqAY?W&w3<$n67+>bU(cF8gE508%yg8lR zgkp2pT$IaF)2ox1a$O<3BIUtR!Rx_wFUWa{xH|TUgNyv_j}^bHPph;PxA{W52h)S> zW`3ftV_#y0_eK#D%{r1#p$l9e?hU(2bUEm$p-jN+&b%{Yk^a!Pcv5}bejennt?W-jOd zkzejOB3JFiq;|me{r#OL_pck(I=lJU?s6(*|JAs^R>^ScY&)hnPahlirC3)(?}iH~ zRUC)S2)voWBXIf-H|E;Wo+v%CC+r^yLA?ab$vs5g^5s_#ec*J5GJ)VC{?fi|peerw zjry$cLc|b>m+~3(=ip|gk%^cgnn|!rMLs%)+v0Cxe#O>(6hbEsOjH0n?{zFU)~IZVKGOSF@~#1dWg&$?rUKj0JKH1ThQbAqGN%{001~mEez)&-8H^dNRQLPUiM!ygz9&txJC}bQ%|9Z*n z5q8t~b>tW>^NUt>aE+qa-<>AHS+lu?YTUkmnp-iZ(_8N}Xd%59 zWES>Vxe1`C?rSnvneLR*$|!0gazSqX+j5N{skhq+NrCwhx)-qv5~PTE@Oh+1KQ<0n ziNS8X=TdfGFnAdcY%P;I1u1va6F2?Rvccz{?b0i{5uc2bW5w>gV%%9yrkuKPLYQfK z`mV&z{p;!dD(TE+?inszzvy7~$Pr3VVEd%Q<0NeQEu_vWsuy|Q$2mz0k?s|$PCqv+ zCE9IahEulx5xY87{7idJ`?hw&if4XiUGccv?PTv2Lzm8ANibhOjn_w!(OME$anrvzp`l#!qjRuT0;6h=z#@+Ag7ne{f(J=F?Rh+1 z&=JwIQ^rUHP{(^sd0gZLY3_z*DT;Nl!n}bOAD`>Y7Q($ju(X#r({Dm0M{dapNJtK9 zSN`3R6UXX3)d!6&(#(R#(&d%7 z;gRv$${Ft_ai`e30KVN6M>`9H&VUR#6QTI zGp1SUMP2;kfA>uxsh{(02F*zYs;;qbJ48ZV!d?#=UFP z@L3;P;7$hOYt#VLE1mL&vjc)Z#(t0QBKDKASXd6dR;5gwD(@J0COfyov;A{)AO2(TwJx z54{CA6LUR8QtzsKw4sTK{=K6%`0iuPNC3VWj!?9syoYL8vLGP8I@x-gGpx19*Q0|& zlHsGf7tfqoIg4-lu=K6AU5fng4OZ4Oo-A`r(MGC+N7u7%Z74B-`B5sc)Zb^XO-vR5 z^A`ifdf;;qtAzGEvLNwp9lAXhK(n@A>Tc~@9aYX zm4$_t@fo;yu6A#&OWE0Vw7vG+Vj%cX2Pp8Lol9lqpF&vtKAvQnb_B2^j@Cp#Z8#r7 zyHnJm@eWb^qt&{uyRfU(l~m-`Y{96+pOc(gfx`Zowp%mSb5Upt>!rq{qxeS&r9yIvo%Rhi-`D;YMw!7q4fXxHK@Rvc9rEoOqiT3qzB&e71DL`|{_CqghX$vR4UzMP z$fr*CF!N4~VYv^^t9y`6x!oj|7|vsu!Y^w&PgiYfIC~vScgC;=6)>8iQ-Gk$wUNcm z(lvBcH|BHu5hJT^fXX95JPY-3!103Q#Z_Zql6if*R9CSvR3DGgu;QFmLH>hj=lKO) z`!JTqw*{pgZ=R*rt~@x2H;lX65%-nDC$#v%uQB~_7c=u3_B8HWp-h4YLkoY);N1}G z8h?5Dq2DVS)9d&a)oB6sl7V#{+r^J(ueB2}r3oFDDN= zYe#HB`c7R{GK4DauXRFOsQyBCu)c`XFY929$Fa-5oIe@Jr^bHaRU-GpqUEaIyBIm*@XS5#57w}&0C*sTRJ7X`Pkbfw0RjMldnOoNk3w&O)9qh=TrjmuPQEjLrZCSohEDk1Ueq{73JDoo-3~`p zKSfn2G437uBDf$b#_%#0{55k%`DVe*RRyYuWh3U{)nHoVdWETI|r`anT*DD3%F6c^bR;@0Xg+}=}*QP1j#s^tJ8wN zNS%2Q)uBOA#2Hv5Wpl)4KjYo77Cq$kA<aKOGWC4IP^<*UNou4TSPD8T+~W=wqAMUKpe`h*SNlc{7%go3 z>%`{rnN6#`)2u<9Idl*2BEElQ_)!aeB%E@kxoNILnhg;je6+eJ>BlhfmGJXp1?`#! zR%Rz~-485nK1NT?bjZ%cs)wJ-uGAmHt(zMpJ1OEf!%9$&%Ru9|sRF}~H~ zdMl{#`kT}o7J6Ou{aqv%J8YA2$QSjt&#}MPm~EG|Tb}5=d8$(UdZ+hw!h16E{3_(f z6V<-denpFzrS(mq?oZ~Pb68Zan`&J-`oFaR=%2`UKF*|9S;_4I+p2}z$b`6vc2VrQ zy~{>#i5PE*QuTJSvWCxx{0%v2o%RpV!_Iv7tH70UYj~G&k!JEaCjnynC!(^S;RiTR zs{iznm3v0~@fR!}e@7q8Jnr7xuRbSz<9KTQvuc5&%Oh`~_0R8k(VAT~*i}xcf0=dC z;j_2sW7KLi)H4}y%o`BJ)Emd#aWLPm(JN~b&v=+1P1@ReO4!D}kVm})eXtvk6r~JY z(v=sg(d*L3MfuQKgd;SCgZ|vii+^~NM(a}l5ocVYh~Av;Je6t3ow0@mf%y9x`qV-d z&MG<{og^bRtINuhN@`K-E<*<3XnCl%vT{)E)A%1Y}eI%SH8%0F`UD?iJ=Y3G% zuwUEeY9Z}`{4_i0qJ`3!okHZvZMp+c*~?aE!Yu6W(cQ*yel|mxPJ!@7^o_x*=clW0 zP|oF6OH&X~L^8HOL1+_kox$uX(TE&9{#RY#&a5DOE$O{!36YKG9p|mzL9P7;%FFSo z6O_CyzTRKv{^^h_z8+r*AyULYF%ar^NMl~6ULL_|g0$}V^@#B}x^os70NK0TOEXHu zN*+HabZHD#=oT*#Zk7P;t%sIw<*{LVS;!Futj{#&F(lZ2kzxp`JUc?ZOvpEsN``H}wa zcqWou+t&+?!Yd=G#ycu?d>jdcY!d+KdJtyIL{!a1!Pp)cm$?ox)`X>mpBzYTop z+`}HCLQWGq=a7*Clk?2WmsF45agXnu8w0+k3VfMgkiWV;Z1L~5Ug=%xBUwrCHml*N z`=^iq#|G&uom*X_hMtE{jyax2ZVry}@x4ph_tM~E*(f?fN?*-Sh4# z-<>W`O4fX1APa4xMXQG4hJUVIIHHaO)y5jBw=f7{?;z+>~Z8Bvce zIRm?^;-NP3XC|LD@0*1<{Ny&C7`o5vpM$6X9t}{i9g>$qKE1E5OZsvH%AL-$qeeS| zm+kvm7Qc8Jx5k7#bLlpt5RuBO0`3vo-SYRmZWFxk zc^kgpk-RSSr@%tuyjx<8q$80VJ5a6Kn869iYQ2Ui+@s#Bj{@Eb+L4#EV~fQ3Midp0 z&Xt%z6EGDI!Ka(anzt%;f8j4m+MqmL&8JJg^V%RqcWS@D7PJQIFO*#`}@CfG=?U>Tgk!%7*Lz<@tU z#dtfau7JlujG$WU?Ode2hiZ7XkV^qSZ%auv?(;pBP*UG&az~9MU;>@Ui z`0&1yCi6&s8L3h$F9+_1cF?go^uZzQZ<(~eSO%mViE81lRe;C9*35U~_PsrGF?Mr! z<;VKKqvcR`*FzM2sl`TJMuJLpBKh(z?gYBqJ`t)!@wlMNe&UDVW-&ey0Oy@Sps$;JC>X*$fpWk)@M>g|%Vv(IU5=o^V>lBrs z&oEp-ivA_6i|AEqh%A*~z7+tz**R_K<4+nU-c3vLI4C?&D@oZPApig`T)gI1NisKz zNB@e$P5mVUi&u2Ps-yx{3ljoIM|+!sZ(IvuPz<(!9Tr*^hit^}3DMZpG+K zCrl5ZDd1uv2bOJfj?Bc&XR=ojPRg#vCNw6o6bKI_iwx4zMJNA>x}jV(irL>=Y7iZFP==rWEULacE&N z18PY;yvFRS2UvUsydhe-pHDu`Vi3+>6PtN;0evAjwyXt*?l;Mw5Gdo=98q7)gcY?>Z$PM{Tj zQCry@fvET)- zj*}ExORdRWETsusT6Pz?V6*Nm^hzMLZ1CO{-(n;zQCJJ)uop83_>)<+W+X~_9fHmn z85?6h<;YXMhj@yQCSPFR;LU!j{N4x6YpbnYsMEIE-tX;1$_05m&j2Umyf@BM2t<1! z@A|Dt<3ANc_H4yw@(weF5WDGuz%@EN>XmkU5aZ&_e49wiU)|L1DY87-FSuTt ze6jnP7Jgdm;W#Ap^zS_<@hqw1yp5Sfg@kNs@j9s#v6@8Zu5%d8QoT6%C#~-9mGEcGANX2>jxjw=241_SlmMFgx zUC;ddRrg3ZT@~TfZ3C0oAg1#Q*Otb(mJ$~$oU~viw`Uf}HEN#>!1c7qi|bOxuA^wCbyGy6pLOnNWPU`ZKCD&+p4+zl zP^B=&$Z8aArC{qkpNN%4Yc4RS#Xv29x#HDWZ1`JojV=T@vm$^rU0bP;W*8PqA#r)n z0;B?*PcU*YJy|Q%5&a6$t(;wA?cCwSb|SUkF9yS@M2Fva^W_RL?}%H4ALZklrPBE> z&IHgfV)Hbl(sD8S(gvFXU+L+lY8vTPwD*@5;yG+S{+0tL-)2+}QF=Eco4l0@XqMp_ zEe+Ne_1`VdZ1kmvGn`f+*`=aS2>)V9ZWI~fz(hcJ#I{mumQ*S!}yYx2K{_{qN$jcIeV2rlT-}_fB;04{^ z;w~$qbJaPbUh1iZs-NaoalxhSyz12ao5LIQa1r~MOb7@&|8~pc<=HY4U~epSW3h`H zu^PRuS*9x&$xfx-Ok@_<9@B-&eOS6b_-LRs)X z>fdZdLBN+@i`W)Kcys#llqXu-c#t#&R)j9oUZCWb@1ZtI6^zaGfz=65hG|%U^C3l^FE*gi7$<^JCwL zH>r>fr;GxA0qjZ773|*>J9$|tN3B_`6|3BvY^)bt@~@J5XAdZFYRxnwIx?7%y7(|m zD~}x}AIQg96_I-P4k$>xrQ>QRDr3_?(CEnWgLflxsj)})u+h)TBb{;98mQQqp@Pgaj+r<8 zMrL0lZqm@#6GYF(FvD?h-u_tdPp=|wDL9w6Fpo?$Jyt5^?x!OIyt>~}p`dqxsZOMI zqi2E4AKtviw?YYDouSDx_hy>!W$P*eb%g?>m2Z^gP z>GNR$c#h`$>IQa~q57R@>u4d)< z&6!TA_4kT-%L8dmlBz2ol4LNxRM_SJ;Kne_&F>?KI>%ASrh%8x{JMU#cu9_D0A9s< z8b|!5HL^Z3TADi?+pQ&0MeGP{3?BTTAc&L`xAx=rtJdyz@=GY24^%Mnj8`R<3)^GY zPLJG!+vXzsobyH za&?6(xH4>|*2wVjZZrwE~i9<;Z`FP_#HA#U`i325Tb`42(1wOJ(N7!Bp)M-&Ll5mik{kQ|MwD_jHCm*k&-9o>F5&t~Y9_`5ieY)bTA8KSd65Y27#Fb(y> zICdE2WTWOY)j4`&3>V|VW;HC7`$h=i-F=@GU>}x`=Lt+Yt8BeN6?KbLIkq)!l~#)( z{u`j&T>WYJx9=TU3d!~q_L7zamq?8hrIrPUSy$Wg>Z&hmT~IS_ljS@yoma6hHTTrE zPNIDATukmH#&e4kbKXW_fe$*E_?KWg8Pp8=g5?Riq;Ns7XJ)2yzR`!Jx{{tts0ni@ z;=`plqc79IQLba!(BD|k(lXOBB)c`toFS*m2TZ|yUtNQrx9k{dj5MEV&qeqm$eU7d zihPMr*(AH9w*Q7=SeYzy2LW}tCN+;n1?9&X$@X#GrAXVxjDvbdB|)OH3{6^g^W7Bm*bZy}6;Vhp zxiNp;jS1|2SV&(zOX|UU-VsJ#&8PI>-FR`iN1v`>L%Kj_Xw-YAlKzW*o;ma zUa8BCV|#`Gv8gi=f1a~Nw=T|A_bcuxj%3e|ymvM{qbmeg0XqXO6*B%R*o;?8WANx6oc%r7W+V$BuRHw5^X z>o$Q}VuuJl#u)KfITdVMSfLIo%RHVHef+o)A>ZV$g{j+6GxS2wI)A2&BNQKn-c@%l zkGk#%T`+;DaXbXyMdq%!66l3ePDazU-?EsfP>?(9*rm#zQ`CQ7VH5>OvLm-6#89?p z1^AKdmIa$(QtY1BPtp6PZJ9+0djD{Rl?uWtd5^wDw45q#yDf5_?Z#9*e{ra<16s||q|&aqP^?S(tq6rEl_C~yL?<$FglIesYf z*eFLzouVe?vtMHDh^aJZWD|j$LmXyZLr}#BM{Lr_x*Aa^04DUwFAP97YCPo0!@i$o zTIBPE1>xMIrTsLml5ATH*z1z$w+fB+cWKa#Zq^_R>tvXD~B= zd4GP{^~N%Z9@DKNYTj62(mkY@X$x?FUaGKka&eUDvX0$)PAu@w5C(NAgLYsJjpgJ=g7M}-&`7fd z+c_L7TA%!zw+BN2K^S&LwjR`(IOzPBZsyM@LBGC*LQIjE^j&t1~{rpBGgP0&M zqCk@sxt*G=3uk%q;+GfhUAXMS!wz z=0l{_=au`(2LC{#$5@#LI3Y~0LxBAJIxLlCADkpK`9r7R{*D}28IUBmg4a~PigFE| zEqw2lE*t;2sc~O*kxYVg$lJu@^eIdsyK9{vQcM9QW z>aIh%E5#l9+;@6%jYCH9X&}2Qv1)`djQ2rNkL2}p!L=;DVehR;_+Pu@!gNB?xV9e5 zlS)axvQ<+ptL%Jy{s{Jj2)tvv%yzF+RRHH!%lq?J4YOYlreoZf^!Y4VKb zYUEr5c9OL`^Y2{IIV0$WZtlxHiDJ6pAowJiO`~RDr{%uCK@m?CGAylD`St!N4oK$7 z|BAmyIbRkW01%>RL%zCIDxxM1UC(9h<)^W}cACvtqL%jY(V+u-&&9}4VESwRa?aAD zf9%D~Fjr?>RCfG`u#!PR7kgTmWYP)g8)Q_An`tCDP|VqQZun467J8~&Pk$>8Gz)z! z$sx!s4aqre^}6jb`K;qa;akp|1VigL_SU&INJ=y)6o$_;ch-~9!o0^LB-T&1SOKLA z&-*O1=nlrkOE!oJ2tKX-y)i@c9xz8h1CU6MDX+{U)1zJ8935CK6@!q;kbL*vDD2zL z7Ya=xduKS$+WK#-@Fz-Fr&lWzJfP32A`HQGq2D!yv95xcla`7W-=e5rk8zQwm^PRj z0x=1!;r#H-7X$^>B@g@szX}J<$NY|i#|n+e2lafrYb(hcjE$}^5`3x9&_SEMP0w`0 zK1WzjBO}*3!;2)OV5(nwWK9k7J9keuk-%gD9Dbn20w(te)HSk+;`$97SyHfGjExaR z7vRP>b#likNp*-%SxmbZN45sv=^2k3eGbKhb3CMZz2)J9nj41qKM&D(p}U^u+AjVe zFTwQRnsN!x5%vW29v1jnu#D!hj=n(h7Jm$r;Y%r!s6Lxdhei>QG9PU>2j^Bw5=5`T zR?*O>sL!2m)k1kHY*2maWP3Dnf}1{YG?*#VY+cW#y^`V(Bb}q{9FOVQ*+0ud&InJE z_%J@^^|BEVQZe^zWVMzFF5*GuK!Q*vyObR(-g8`+uhhHgwP(bzgIp<7-c4RO%F?~6 zCi#mzB8ch%cY3>&L{9{NKV-7N6Qi%L&0$Ka5VX5L~0t6~|oPr!|6FZD2jGZIFzQhvj|nw?DpY-uEUj^>4xsrB#d}ZcHG;iVY^e zQKkTy5>y_Ww(_`y1fZ1nfwCI3>g1a1{X+}XcnKPg-Ir)+c`i2=k}R0${wuQI>Zy{i zgXu;PV!0DvAMS8my0-FOQz%3`BrO0F8CQ2wfsEtK!Dr5e7*8}VRHMW&R)K1m-hP&v zNsUOq39RF{@R3eSqXM}%@`9|s5OnnX@R^QuAv@1i62Ung20WPmw7P1SqG>D@g2@b84gg7cDs5vRWF84om2e))Nx zd(!J!*^4EC!SJ__VyhUIibwciAEP(AQ#ObKnTwqDXV|r+dN_CTdHcXI)Vo2nMa~FL zYXHx29E7vms2(GG(Blr>Dy1FWg}~0;ATzV~Bip$?^qEE@GmShpUb7HoSA`5?alZvx zW`Ld(HM-Ataz6KKSq8p3}>1rej|w zW_yquOtJSP@7BfJcLBkDB*1cr}lN!;g`t#!Whi-(=8bSUg>deTA7hnIj88{Iv( zVs$||cS@=)34OXW8hnV`jN0!^v*{U>GkP80y|Ch-6i#-d-&r{1J_PE77u@gHbG zlVQsyKWHLY17U>k!aiOCU=lGpCC;W%x;nBLm<2l^OC!)L3V7jLUU8GBiwP{K9~DV> zc$-R&*V(`&*Jc!Et>bu))(h4BIF!ekn=LNMHnz8b^dj?~ohwzSik#3pwXNWqOHnrQ z`~I!!-IMlVfhORT&69tl_$Z)T1SJ8h*ZGjAni_ms(DaJ=XLLKh)TH9d6q+{q7eh{T(!ROP?XF^AFj6Q zrf)5{+I}5H9D)Tolf7~-uel1%Q`%R%So2ecAPgmBI)e9{*?2XeHc%``&EPg8FZRhJ z;yE$Y3QCPS^_a!wB>gsy_od!{hQ&cAEIUJ5-~Pv{bXT`#+=#*{1+WLJD`C0VgJD`T zlDsHN=m}~B^@0SXF?7f8$2qSyWxWgWbUF*ydR=qY>N=7(g56%atCH2u ziR=!cv=9?sX)m{JAzZe^$G#)HU%lg$b0yMtus@PwoXVP*&<0hy7MhyTXsfl-%_b#6X;!AiC667j#D%El$BB>f;2-Zg$=l=<-Fck%RAKUE%Tl8Wi`)TS0W6!g3&caTNcU27qmJw@RRAP*! zg5fi Date: Mon, 19 Oct 2020 10:18:09 +0200 Subject: [PATCH 02/10] MOBILE-3565 components: Implement show-password component --- src/app/components/components.module.ts | 4 + .../show-password/core-show-password.html | 4 + .../show-password/show-password.scss | 38 +++++ .../components/show-password/show-password.ts | 136 ++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 src/app/components/show-password/core-show-password.html create mode 100644 src/app/components/show-password/show-password.scss create mode 100644 src/app/components/show-password/show-password.ts diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index 67c7a4a6c..27ea76157 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -13,15 +13,19 @@ // limitations under the License. import { NgModule } from '@angular/core'; + import { CoreIconComponent } from './icon/icon'; +import { CoreShowPasswordComponent } from './show-password/show-password'; @NgModule({ declarations: [ CoreIconComponent, + CoreShowPasswordComponent, ], imports: [], exports: [ CoreIconComponent, + CoreShowPasswordComponent, ], }) export class CoreComponentsModule {} diff --git a/src/app/components/show-password/core-show-password.html b/src/app/components/show-password/core-show-password.html new file mode 100644 index 000000000..f71a8a10f --- /dev/null +++ b/src/app/components/show-password/core-show-password.html @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/app/components/show-password/show-password.scss b/src/app/components/show-password/show-password.scss new file mode 100644 index 000000000..26188a26b --- /dev/null +++ b/src/app/components/show-password/show-password.scss @@ -0,0 +1,38 @@ +ion-app.app-root core-show-password { + padding: 0px; + width: 100%; + position: relative; + + ion-input input.text-input { + // @todo @include padding(null, 47px, null, null); + } + + .button[icon-only] { + background: transparent; + // @todo padding: 0 ($content-padding / 2); + position: absolute; + // @todo @include position(null, 0, $content-padding / 2, null); + margin-top: 0; + margin-bottom: 0; + } + + .core-ioninput-password { + padding-top: 0; + padding-bottom: 0; + } +} + +ion-app.app-root.md { + .item-label-stacked core-show-password .button[icon-only] { + bottom: 0; + } +} + +ion-app.app-root.ios { + .item-label-stacked core-show-password .button[icon-only] { + bottom: -5px; + } + core-show-password .button[icon-only] { + bottom: 0; + } +} diff --git a/src/app/components/show-password/show-password.ts b/src/app/components/show-password/show-password.ts new file mode 100644 index 000000000..e2d79fa4d --- /dev/null +++ b/src/app/components/show-password/show-password.ts @@ -0,0 +1,136 @@ +// (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 { Component, OnInit, AfterViewInit, Input, ElementRef, ContentChild } from '@angular/core'; +import { IonInput } from '@ionic/angular'; + +import { CoreApp } from '@services/app'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Component to allow showing and hiding a password. The affected input MUST have a name to identify it. + * + * @description + * This directive needs to surround the input with the password. + * + * You need to supply the name of the input. + * + * Example: + * + * + * + * + */ +@Component({ + selector: 'core-show-password', + templateUrl: 'core-show-password.html', + styleUrls: ['show-password.scss'], +}) +export class CoreShowPasswordComponent implements OnInit, AfterViewInit { + + @Input() name: string; // Name of the input affected. + @Input() initialShown?: boolean | string; // Whether the password should be shown at start. + @ContentChild(IonInput) ionInput: IonInput; + + shown: boolean; // Whether the password is shown. + label: string; // Label for the button to show/hide. + iconName: string; // Name of the icon of the button to show/hide. + selector = ''; // Selector to identify the input. + + protected input: HTMLInputElement; // Input affected. + protected element: HTMLElement; // Current element. + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.shown = CoreUtils.instance.isTrueOrOne(this.initialShown); + this.selector = 'input[name="' + this.name + '"]'; + this.setData(); + } + + /** + * View has been initialized. + */ + ngAfterViewInit(): void { + this.searchInput(); + } + + /** + * Search the input to show/hide. + */ + protected async searchInput(): Promise { + if (this.ionInput) { + // It's an ion-input, use it to get the native element. + this.input = await this.ionInput.getInputElement(); + + return; + } + + // Search the input. + this.input = this.element.querySelector(this.selector); + + if (this.input) { + // Input found. Set the right type. + this.input.type = this.shown ? 'text' : 'password'; + + // By default, don't autocapitalize and autocorrect. + if (!this.input.getAttribute('autocorrect')) { + this.input.setAttribute('autocorrect', 'off'); + } + if (!this.input.getAttribute('autocapitalize')) { + this.input.setAttribute('autocapitalize', 'none'); + } + } + } + + /** + * Set label, icon name and input type. + */ + protected setData(): void { + this.label = this.shown ? 'core.hide' : 'core.show'; + this.iconName = this.shown ? 'eye-off' : 'eye'; + if (this.input) { + this.input.type = this.shown ? 'text' : 'password'; + } + } + + /** + * Toggle show/hide password. + * + * @param event The mouse event. + */ + toggle(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + const isFocused = document.activeElement === this.input; + + this.shown = !this.shown; + this.setData(); + + if (isFocused && CoreApp.instance.isAndroid()) { + // In Android, the keyboard is closed when the input type changes. Focus it again. + setTimeout(() => { + CoreDomUtils.instance.focusElement(this.input); + }, 400); + } + } + +} From a6bf6afe7a330c47e71a966b1b66db97874318af Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Oct 2020 10:34:00 +0200 Subject: [PATCH 03/10] MOBILE-3565 components: Implement loading component --- src/app/components/components.module.ts | 8 +- src/app/components/loading/core-loading.html | 10 ++ src/app/components/loading/loading.scss | 67 +++++++++++ src/app/components/loading/loading.ts | 120 +++++++++++++++++++ 4 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/app/components/loading/core-loading.html create mode 100644 src/app/components/loading/loading.scss create mode 100644 src/app/components/loading/loading.ts diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index 27ea76157..dfb7b2b51 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -13,18 +13,24 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; import { CoreIconComponent } from './icon/icon'; +import { CoreLoadingComponent } from './loading/loading'; import { CoreShowPasswordComponent } from './show-password/show-password'; @NgModule({ declarations: [ CoreIconComponent, + CoreLoadingComponent, CoreShowPasswordComponent, ], - imports: [], + imports: [ + IonicModule, + ], exports: [ CoreIconComponent, + CoreLoadingComponent, CoreShowPasswordComponent, ], }) diff --git a/src/app/components/loading/core-loading.html b/src/app/components/loading/core-loading.html new file mode 100644 index 000000000..da4887db0 --- /dev/null +++ b/src/app/components/loading/core-loading.html @@ -0,0 +1,10 @@ +
+ + +

{{message}}

+
+
+
+ + +
\ No newline at end of file diff --git a/src/app/components/loading/loading.scss b/src/app/components/loading/loading.scss new file mode 100644 index 000000000..afe335a58 --- /dev/null +++ b/src/app/components/loading/loading.scss @@ -0,0 +1,67 @@ +ion-app.app-root { + core-loading { + // @todo @include core-transition(height, 200ms); + + .core-loading-container { + width: 100%; + text-align: center; + padding-top: 10px; + clear: both; + /* @todo @include darkmode() { + color: $core-dark-text-color; + } */ + } + + .core-loading-content { + display: inline; + padding-bottom: 1px; /* This makes height be real */ + } + + &.core-loading-noheight .core-loading-content { + height: auto; + } + + &.safe-area-page { + padding-left: 0 !important; + padding-right: 0 !important; + + > .core-loading-content > *:not[padding], + > .core-loading-content-loading > *:not[padding] { + // @todo @include safe-area-padding-horizontal(0px, 0px); + } + } + } + + .scroll-content > core-loading, + ion-content > .scroll-content > core-loading, + core-tab core-loading, + .core-loading-center { + position: static !important; + } + + .scroll-content > core-loading, + ion-content > .scroll-content > core-loading, + core-tab core-loading, + .core-loading-center, + core-loading.core-loading-loaded { + position: relative; + + > .core-loading-container { + position: absolute; + // @todo @include position(0, 0, 0, 0); + display: table; + height: 100%; + width: 100%; + z-index: 1; + margin: 0; + padding: 0; + clear: both; + + .core-loading-spinner { + display: table-cell; + text-align: center; + vertical-align: middle; + } + } + } +} diff --git a/src/app/components/loading/loading.ts b/src/app/components/loading/loading.ts new file mode 100644 index 000000000..d4d0d0e22 --- /dev/null +++ b/src/app/components/loading/loading.ts @@ -0,0 +1,120 @@ +// (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 { Component, Input, OnInit, OnChanges, SimpleChange, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; + +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Component to show a loading spinner and message while data is being loaded. + * + * It will show a spinner with a message and hide all the content until 'hideUntil' variable is set to a truthy value (!!hideUntil). + * If 'message' isn't set, default message "Loading" is shown. + * 'message' attribute accepts hardcoded strings, variables, filters, etc. E.g. [message]="'core.loading' | translate". + * + * Usage: + * + * + * + * + * IMPORTANT: Due to how ng-content works in Angular, the content of core-loading will be executed as soon as your view + * is loaded, even if the content hidden. So if you have the following code: + * + * + * The component "my-component" will be initialized immediately, even if dataLoaded is false, but it will be hidden. If you want + * your component to be initialized only if dataLoaded is true, then you should use ngIf: + * + */ +@Component({ + selector: 'core-loading', + templateUrl: 'core-loading.html', + styleUrls: ['loading.scss'], + // @todo animations: [coreShowHideAnimation], +}) +export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit { + + @Input() hideUntil: unknown; // Determine when should the contents be shown. + @Input() message?: string; // Message to show while loading. + @ViewChild('content') content: ElementRef; + + protected uniqueId: string; + protected element: HTMLElement; // Current element. + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Calculate the unique ID. + this.uniqueId = 'core-loading-content-' + CoreUtils.instance.getUniqueId('CoreLoadingComponent'); + + if (!this.message) { + // Default loading message. + this.message = Translate.instance.instant('core.loading'); + } + } + + /** + * View has been initialized. + */ + ngAfterViewInit(): void { + // Add class if loaded on init. + if (this.hideUntil) { + this.element.classList.add('core-loading-loaded'); + this.content?.nativeElement.classList.add('core-loading-content'); + } else { + this.content?.nativeElement.classList.remove('core-loading-content'); + this.content?.nativeElement.classList.add('core-loading-content-loading'); + } + } + + /** + * Component input changed. + * + * @param changes Changes. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.hideUntil) { + if (this.hideUntil) { + setTimeout(() => { + // Content is loaded so, center the spinner on the content itself. + this.element.classList.add('core-loading-loaded'); + setTimeout(() => { + // Change CSS to force calculate height. + this.content?.nativeElement.classList.add('core-loading-content'); + this.content?.nativeElement.classList.remove('core-loading-content-loading'); + }, 500); + }); + } else { + this.element.classList.remove('core-loading-loaded'); + this.content?.nativeElement.classList.remove('core-loading-content'); + this.content?.nativeElement.classList.add('core-loading-content-loading'); + } + + // Trigger the event after a timeout since the elements inside ngIf haven't been added to DOM yet. + setTimeout(() => { + CoreEvents.instance.trigger(CoreEventsProvider.CORE_LOADING_CHANGED, { + loaded: !!this.hideUntil, + uniqueId: this.uniqueId, + }); + }); + } + } + +} From 345a26cd7be502d72ee23b729b7230b4da1fd594 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Oct 2020 12:31:31 +0200 Subject: [PATCH 04/10] MOBILE-3565 directives: Implement format-text directive --- src/app/components/loading/loading.ts | 4 +- src/app/directives/directives.module.ts | 3 + src/app/directives/format-text.ts | 727 ++++++++++++++++++++++++ src/app/services/events.ts | 10 +- 4 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 src/app/directives/format-text.ts diff --git a/src/app/components/loading/loading.ts b/src/app/components/loading/loading.ts index d4d0d0e22..baf58d6cf 100644 --- a/src/app/components/loading/loading.ts +++ b/src/app/components/loading/loading.ts @@ -14,7 +14,7 @@ import { Component, Input, OnInit, OnChanges, SimpleChange, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; -import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreEventLoadingChangedData, CoreEvents, CoreEventsProvider } from '@services/events'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons/core.singletons'; @@ -109,7 +109,7 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit { // Trigger the event after a timeout since the elements inside ngIf haven't been added to DOM yet. setTimeout(() => { - CoreEvents.instance.trigger(CoreEventsProvider.CORE_LOADING_CHANGED, { + CoreEvents.instance.trigger(CoreEventsProvider.CORE_LOADING_CHANGED, { loaded: !!this.hideUntil, uniqueId: this.uniqueId, }); diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts index 729290dd5..11fb1fa22 100644 --- a/src/app/directives/directives.module.ts +++ b/src/app/directives/directives.module.ts @@ -13,14 +13,17 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreFormatTextDirective } from './format-text'; import { CoreLongPressDirective } from './long-press.directive'; @NgModule({ declarations: [ + CoreFormatTextDirective, CoreLongPressDirective, ], imports: [], exports: [ + CoreFormatTextDirective, CoreLongPressDirective, ], }) diff --git a/src/app/directives/format-text.ts b/src/app/directives/format-text.ts new file mode 100644 index 000000000..e4c74ebaa --- /dev/null +++ b/src/app/directives/format-text.ts @@ -0,0 +1,727 @@ +// (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 { Directive, ElementRef, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional } from '@angular/core'; +import { NavController, IonContent } from '@ionic/angular'; + +import { CoreEventLoadingChangedData, CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreIframeUtils, CoreIframeUtilsProvider } from '@services/utils/iframe'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSite } from '@classes/site'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective + * and CoreExternalContentDirective. It also applies filters if needed. + * + * Please use this directive if your text needs to be filtered or it can contain links or media (images, audio, video). + * + * Example usage: + * + */ +@Directive({ + selector: 'core-format-text', +}) +export class CoreFormatTextDirective implements OnChanges { + + @Input() text: string; // The text to format. + @Input() siteId?: string; // Site ID to use. + @Input() component?: string; // Component for CoreExternalContentDirective. + @Input() componentId?: string | number; // Component ID to use in conjunction with the component. + @Input() adaptImg?: boolean | string = true; // Whether to adapt images to screen width. + @Input() clean?: boolean | string; // Whether all the HTML tags should be removed. + @Input() singleLine?: boolean | string; // Whether new lines should be removed (all text in single line). Only if clean=true. + @Input() maxHeight?: number; // Max height in pixels to render the content box. It should be 50 at least to make sense. + // Using this parameter will force display: block to calculate height better. + // If you want to avoid this use class="inline" at the same time to use display: inline-block. + @Input() fullOnClick?: boolean | string; // Whether it should open a new page with the full contents on click. + @Input() fullTitle?: string; // Title to use in full view. Defaults to "Description". + @Input() highlight?: string; // Text to highlight. + @Input() filter?: boolean | string; // Whether to filter the text. If not defined, true if contextLevel and instanceId are set. + @Input() contextLevel?: string; // The context level of the text. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters. + @Input() wsNotFiltered?: boolean | string; // If true it means the WS didn't filter the text for some reason. + @Output() afterRender?: EventEmitter; // Called when the data is rendered. + + protected element: HTMLElement; + protected showMoreDisplayed: boolean; + protected loadingChangedListener; + + constructor( + element: ElementRef, + @Optional() protected navCtrl: NavController, + @Optional() protected content: IonContent, + ) { + + this.element = element.nativeElement; + this.element.classList.add('opacity-hide'); // Hide contents until they're treated. + this.afterRender = new EventEmitter(); + + this.element.addEventListener('click', this.elementClicked.bind(this)); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.text || changes.filter || changes.contextLevel || changes.contextInstanceId) { + this.hideShowMore(); + this.formatAndRenderContents(); + } + } + + /** + * Apply CoreExternalContentDirective to a certain element. + * + * @param element Element to add the attributes to. + * @return External content instance. + */ + protected addExternalContent(element: Element): any { + // Angular 2 doesn't let adding directives dynamically. Create the CoreExternalContentDirective manually. + // @todo + return null; + } + + /** + * Add class to adapt media to a certain element. + * + * @param element Element to add the class to. + */ + protected addMediaAdaptClass(element: HTMLElement): void { + element.classList.add('core-media-adapt-width'); + } + + /** + * Wrap an image with a container to adapt its width. + * + * @param img Image to adapt. + */ + protected adaptImage(img: HTMLElement): void { + // Element to wrap the image. + const container = document.createElement('span'); + const originalWidth = img.attributes.getNamedItem('width'); + + const forcedWidth = parseInt(originalWidth && originalWidth.value); + if (!isNaN(forcedWidth)) { + if (originalWidth.value.indexOf('%') < 0) { + img.style.width = forcedWidth + 'px'; + } else { + img.style.width = forcedWidth + '%'; + } + } + + container.classList.add('core-adapted-img-container'); + container.style.cssFloat = img.style.cssFloat; // Copy the float to correctly position the search icon. + if (img.classList.contains('atto_image_button_right')) { + container.classList.add('atto_image_button_right'); + } else if (img.classList.contains('atto_image_button_left')) { + container.classList.add('atto_image_button_left'); + } else if (img.classList.contains('atto_image_button_text-top')) { + container.classList.add('atto_image_button_text-top'); + } else if (img.classList.contains('atto_image_button_middle')) { + container.classList.add('atto_image_button_middle'); + } else if (img.classList.contains('atto_image_button_text-bottom')) { + container.classList.add('atto_image_button_text-bottom'); + } + + CoreDomUtils.instance.wrapElement(img, container); + } + + /** + * Add magnifying glass icons to view adapted images at full size. + */ + addMagnifyingGlasses(): void { + const imgs = Array.from(this.element.querySelectorAll('.core-adapted-img-container > img')); + if (!imgs.length) { + return; + } + + // If cannot calculate element's width, use viewport width to avoid false adapt image icons appearing. + const elWidth = this.getElementWidth(this.element) || window.innerWidth; + + imgs.forEach((img: HTMLImageElement) => { + // Skip image if it's inside a link. + if (img.closest('a')) { + return; + } + + let imgWidth = parseInt(img.getAttribute('width')); + if (!imgWidth) { + // No width attribute, use real size. + imgWidth = img.naturalWidth; + } + + if (imgWidth <= elWidth) { + return; + } + + const imgSrc = CoreTextUtils.instance.escapeHTML(img.getAttribute('data-original-src') || img.getAttribute('src')); + const label = Translate.instance.instant('core.openfullimage'); + const anchor = document.createElement('a'); + + anchor.classList.add('core-image-viewer-icon'); + anchor.setAttribute('aria-label', label); + // Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed. + anchor.innerHTML = ''; + + anchor.addEventListener('click', (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + CoreDomUtils.instance.viewImage(imgSrc, img.getAttribute('alt'), this.component, this.componentId, true); + }); + + img.parentNode.appendChild(anchor); + }); + } + + /** + * Calculate the height and check if we need to display show more or not. + */ + protected calculateHeight(): void { + // @todo: Work on calculate this height better. + + // Remove max-height (if any) to calculate the real height. + const initialMaxHeight = this.element.style.maxHeight; + this.element.style.maxHeight = null; + + const height = this.getElementHeight(this.element); + + // Restore the max height now. + this.element.style.maxHeight = initialMaxHeight; + + // If cannot calculate height, shorten always. + if (!height || height > this.maxHeight) { + if (!this.showMoreDisplayed) { + this.displayShowMore(); + } + } else if (this.showMoreDisplayed) { + this.hideShowMore(); + } + } + + /** + * Display the "Show more" in the element. + */ + protected displayShowMore(): void { + const expandInFullview = CoreUtils.instance.isTrueOrOne(this.fullOnClick) || false; + const showMoreDiv = document.createElement('div'); + + showMoreDiv.classList.add('core-show-more'); + showMoreDiv.innerHTML = Translate.instance.instant('core.showmore'); + this.element.appendChild(showMoreDiv); + + if (expandInFullview) { + this.element.classList.add('core-expand-in-fullview'); + } + this.element.classList.add('core-text-formatted'); + this.element.classList.add('core-shortened'); + this.element.style.maxHeight = this.maxHeight + 'px'; + + this.showMoreDisplayed = true; + } + + /** + * Listener to call when the element is clicked. + * + * @param e Click event. + */ + protected elementClicked(e: MouseEvent): void { + if (e.defaultPrevented) { + // Ignore it if the event was prevented by some other listener. + return; + } + + const expandInFullview = CoreUtils.instance.isTrueOrOne(this.fullOnClick) || false; + + if (!expandInFullview && !this.showMoreDisplayed) { + // Nothing to do on click, just stop. + return; + } + + e.preventDefault(); + e.stopPropagation(); + + if (!expandInFullview) { + // Change class. + this.element.classList.toggle('core-shortened'); + + return; + } else { + // Open a new state with the contents. + const filter = typeof this.filter != 'undefined' ? CoreUtils.instance.isTrueOrOne(this.filter) : undefined; + + CoreTextUtils.instance.viewText(this.fullTitle || Translate.instance.instant('core.description'), this.text, { + component: this.component, + componentId: this.componentId, + filter: filter, + contextLevel: this.contextLevel, + instanceId: this.contextInstanceId, + courseId: this.courseId, + }); + } + } + + /** + * Finish the rendering, displaying the element again and calling afterRender. + */ + protected finishRender(): void { + // Show the element again. + this.element.classList.remove('opacity-hide'); + // Emit the afterRender output. + this.afterRender.emit(); + } + + /** + * Format contents and render. + */ + protected async formatAndRenderContents(): Promise { + if (!this.text) { + this.element.innerHTML = ''; // Remove current contents. + this.finishRender(); + + return; + } + + // In AOT the inputs and ng-reflect aren't in the DOM sometimes. Add them so styles are applied. + if (this.maxHeight && !this.element.getAttribute('maxHeight')) { + this.element.setAttribute('maxHeight', String(this.maxHeight)); + } + if (!this.element.getAttribute('singleLine')) { + this.element.setAttribute('singleLine', String(CoreUtils.instance.isTrueOrOne(this.singleLine))); + } + + this.text = this.text ? this.text.trim() : ''; + + const result = await this.formatContents(); + + // Disable media adapt to correctly calculate the height. + this.element.classList.add('core-disable-media-adapt'); + + this.element.innerHTML = ''; // Remove current contents. + if (this.maxHeight && result.div.innerHTML != '' && + (this.fullOnClick || (window.innerWidth < 576 || window.innerHeight < 576))) { // Don't collapse in big screens. + + // Move the children to the current element to be able to calculate the height. + CoreDomUtils.instance.moveChildren(result.div, this.element); + + // Calculate the height now. + this.calculateHeight(); + + // Add magnifying glasses to images. + this.addMagnifyingGlasses(); + + if (!this.loadingChangedListener) { + // Recalculate the height if a parent core-loading displays the content. + this.loadingChangedListener = + CoreEvents.instance.on(CoreEventsProvider.CORE_LOADING_CHANGED, (data: CoreEventLoadingChangedData) => { + if (data.loaded && CoreDomUtils.instance.closest(this.element.parentElement, '#' + data.uniqueId)) { + // The format-text is inside the loading, re-calculate the height. + this.calculateHeight(); + } + }); + } + } else { + CoreDomUtils.instance.moveChildren(result.div, this.element); + + // Add magnifying glasses to images. + this.addMagnifyingGlasses(); + } + + if (result.options.filter) { + // Let filters hnadle HTML. We do it here because we don't want them to block the render of the text. + // @todo + } + + this.element.classList.remove('core-disable-media-adapt'); + this.finishRender(); + } + + /** + * Apply formatText and set sub-directives. + * + * @return Promise resolved with a div element containing the code. + */ + protected async formatContents(): Promise { + + const result: FormatContentsResult = { + div: null, + filters: [], + options: {}, + siteId: this.siteId, + }; + + // Retrieve the site since it might be needed later. + const site = await CoreUtils.instance.ignoreErrors(CoreSites.instance.getSite(this.siteId)); + + if (site) { + result.siteId = site.getId(); + } + + if (this.contextLevel == 'course' && this.contextInstanceId <= 0) { + this.contextInstanceId = site.getSiteHomeId(); + } + + const filter = typeof this.filter == 'undefined' ? + !!(this.contextLevel && typeof this.contextInstanceId != 'undefined') : CoreUtils.instance.isTrueOrOne(this.filter); + + result.options = { + clean: CoreUtils.instance.isTrueOrOne(this.clean), + singleLine: CoreUtils.instance.isTrueOrOne(this.singleLine), + highlight: this.highlight, + courseId: this.courseId, + wsNotFiltered: CoreUtils.instance.isTrueOrOne(this.wsNotFiltered), + }; + + let formatted: string; + + if (filter) { + // @todo + formatted = this.text; + } else { + // @todo + formatted = this.text; + } + + formatted = this.treatWindowOpen(formatted); + + const div = document.createElement('div'); + + div.innerHTML = formatted; + + this.treatHTMLElements(div, site); + + result.div = div; + + return result; + } + + /** + * Treat HTML elements when formatting contents. + * + * @param div Div element. + * @param site Site instance. + * @return Promise resolved when done. + */ + protected async treatHTMLElements(div: HTMLElement, site?: CoreSite): Promise { + const canTreatVimeo = site && site.isVersionGreaterEqualThan(['3.3.4', '3.4']); + const navCtrl = this.navCtrl; // @todo this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + + const images = Array.from(div.querySelectorAll('img')); + const anchors = Array.from(div.querySelectorAll('a')); + const audios = Array.from(div.querySelectorAll('audio')); + const videos = Array.from(div.querySelectorAll('video')); + const iframes = Array.from(div.querySelectorAll('iframe')); + const buttons = Array.from(div.querySelectorAll('.button')); + const elementsWithInlineStyles = Array.from(div.querySelectorAll('*[style]')); + const stopClicksElements = Array.from(div.querySelectorAll('button,input,select,textarea')); + const frames = Array.from(div.querySelectorAll(CoreIframeUtilsProvider.FRAME_TAGS.join(',').replace(/iframe,?/, ''))); + const svgImages = Array.from(div.querySelectorAll('image')); + + // Walk through the content to find the links and add our directive to it. + // Important: We need to look for links first because in 'img' we add new links without core-link. + anchors.forEach((anchor) => { + // Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually. + // @todo + + this.addExternalContent(anchor); + }); + + const externalImages: any[] = []; + if (images && images.length > 0) { + // Walk through the content to find images, and add our directive. + images.forEach((img: HTMLElement) => { + this.addMediaAdaptClass(img); + + const externalImage = this.addExternalContent(img); + if (!externalImage.invalid) { + externalImages.push(externalImage); + } + + if (CoreUtils.instance.isTrueOrOne(this.adaptImg) && !img.classList.contains('icon')) { + this.adaptImage(img); + } + }); + } + + audios.forEach((audio) => { + this.treatMedia(audio); + }); + + videos.forEach((video) => { + this.treatMedia(video); + }); + + iframes.forEach((iframe) => { + this.treatIframe(iframe, site, canTreatVimeo, navCtrl); + }); + + svgImages.forEach((image) => { + this.addExternalContent(image); + }); + + // Handle buttons with inner links. + buttons.forEach((button: HTMLElement) => { + // Check if it has a link inside. + if (button.querySelector('a')) { + button.classList.add('core-button-with-inner-link'); + } + }); + + // Handle inline styles. + elementsWithInlineStyles.forEach((el: HTMLElement) => { + // Only add external content for tags that haven't been treated already. + if (el.tagName != 'A' && el.tagName != 'IMG' && el.tagName != 'AUDIO' && el.tagName != 'VIDEO' + && el.tagName != 'SOURCE' && el.tagName != 'TRACK') { + this.addExternalContent(el); + } + }); + + // Stop propagating click events. + stopClicksElements.forEach((element: HTMLElement) => { + element.addEventListener('click', (e) => { + e.stopPropagation(); + }); + }); + + // Handle all kind of frames. + frames.forEach((frame: HTMLFrameElement | HTMLObjectElement | HTMLEmbedElement) => { + CoreIframeUtils.instance.treatFrame(frame, false, navCtrl); + }); + + CoreDomUtils.instance.handleBootstrapTooltips(div); + + if (externalImages.length) { + // Wait for images to load. + const promise = CoreUtils.instance.allPromises(externalImages.map((externalImage) => { + if (externalImage.loaded) { + // Image has already been loaded, no need to wait. + return Promise.resolve(); + } + + return new Promise((resolve): void => { + const subscription = externalImage.onLoad.subscribe(() => { + subscription.unsubscribe(); + resolve(); + }); + }); + })); + + // Automatically reject the promise after 5 seconds to prevent blocking the user forever. + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.timeoutPromise(promise, 5000)); + } + } + + /** + * Returns the element width in pixels. + * + * @param element Element to get width from. + * @return The width of the element in pixels. When 0 is returned it means the element is not visible. + */ + protected getElementWidth(element: HTMLElement): number { + let width = CoreDomUtils.instance.getElementWidth(element); + + if (!width) { + // All elements inside are floating or inline. Change display mode to allow calculate the width. + const parentWidth = CoreDomUtils.instance.getElementWidth(element.parentElement, true, false, false, true); + const previousDisplay = getComputedStyle(element, null).display; + + element.style.display = 'inline-block'; + + width = CoreDomUtils.instance.getElementWidth(element); + + // If width is incorrectly calculated use parent width instead. + if (parentWidth > 0 && (!width || width > parentWidth)) { + width = parentWidth; + } + + element.style.display = previousDisplay; + } + + return width; + } + + /** + * Returns the element height in pixels. + * + * @param elementAng Element to get height from. + * @return The height of the element in pixels. When 0 is returned it means the element is not visible. + */ + protected getElementHeight(element: HTMLElement): number { + return CoreDomUtils.instance.getElementHeight(element) || 0; + } + + /** + * "Hide" the "Show more" in the element if it's shown. + */ + protected hideShowMore(): void { + const showMoreDiv = this.element.querySelector('div.core-show-more'); + + if (showMoreDiv) { + showMoreDiv.remove(); + } + + this.element.classList.remove('core-expand-in-fullview'); + this.element.classList.remove('core-text-formatted'); + this.element.classList.remove('core-shortened'); + this.element.style.maxHeight = null; + this.showMoreDisplayed = false; + } + + /** + * Add media adapt class and apply CoreExternalContentDirective to the media element and its sources and tracks. + * + * @param element Video or audio to treat. + */ + protected treatMedia(element: HTMLElement): void { + this.addMediaAdaptClass(element); + this.addExternalContent(element); + + const sources = Array.from(element.querySelectorAll('source')); + const tracks = Array.from(element.querySelectorAll('track')); + + sources.forEach((source) => { + source.setAttribute('target-src', source.getAttribute('src')); + source.removeAttribute('src'); + this.addExternalContent(source); + }); + + tracks.forEach((track) => { + this.addExternalContent(track); + }); + + // Stop propagating click events. + element.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + /** + * Add media adapt class and treat the iframe source. + * + * @param iframe Iframe to treat. + * @param site Site instance. + * @param canTreatVimeo Whether Vimeo videos can be treated in the site. + * @param navCtrl NavController to use. + */ + protected async treatIframe(iframe: HTMLIFrameElement, site: CoreSite, canTreatVimeo: boolean, navCtrl: NavController): + Promise { + const src = iframe.src; + const currentSite = CoreSites.instance.getCurrentSite(); + + this.addMediaAdaptClass(iframe); + + if (currentSite?.containsUrl(src)) { + // URL points to current site, try to use auto-login. + const finalUrl = await currentSite.getAutoLoginUrl(src, false); + + iframe.src = finalUrl; + + CoreIframeUtils.instance.treatFrame(iframe, false, navCtrl); + + return; + } + + if (src && canTreatVimeo) { + // Check if it's a Vimeo video. If it is, use the wsplayer script instead to make restricted videos work. + const matches = iframe.src.match(/https?:\/\/player\.vimeo\.com\/video\/([0-9]+)/); + if (matches && matches[1]) { + let newUrl = CoreTextUtils.instance.concatenatePaths(site.getURL(), '/media/player/vimeo/wsplayer.php?video=') + + matches[1] + '&token=' + site.getToken(); + + // Width and height are mandatory, we need to calculate them. + let width; + let height; + + if (iframe.width) { + width = iframe.width; + } else { + width = this.getElementWidth(iframe); + if (!width) { + width = window.innerWidth; + } + } + + if (iframe.height) { + height = iframe.height; + } else { + height = this.getElementHeight(iframe); + if (!height) { + height = width; + } + } + + // Width and height parameters are required in 3.6 and older sites. + if (site && !site.isVersionGreaterEqualThan('3.7')) { + newUrl += '&width=' + width + '&height=' + height; + } + iframe.src = newUrl; + + if (!iframe.width) { + iframe.width = width; + } + if (!iframe.height) { + iframe.height = height; + } + + // Do the iframe responsive. + if (iframe.parentElement.classList.contains('embed-responsive')) { + iframe.addEventListener('load', () => { + if (iframe.contentDocument) { + const css = document.createElement('style'); + css.setAttribute('type', 'text/css'); + css.innerHTML = 'iframe {width: 100%;height: 100%;}'; + iframe.contentDocument.head.appendChild(css); + } + }); + } + } + } + + CoreIframeUtils.instance.treatFrame(iframe, false, navCtrl); + } + + /** + * Convert window.open to window.openWindowSafely inside HTML tags. + * + * @param text Text to treat. + * @return Treated text. + */ + protected treatWindowOpen(text: string): string { + // Get HTML tags that include window.open. Script tags aren't executed so there's no need to treat them. + const matches = text.match(/<[^>]+window\.open\([^)]*\)[^>]*>/g); + + if (matches) { + matches.forEach((match) => { + // Replace all the window.open inside the tag. + const treated = match.replace(/window\.open\(/g, 'window.openWindowSafely('); + + text = text.replace(match, treated); + }); + } + + return text; + } + +} + +type FormatContentsResult = { + div: HTMLElement; + filters: any[]; + options: any; + siteId: string; +}; diff --git a/src/app/services/events.ts b/src/app/services/events.ts index 505379a90..195fde5a9 100644 --- a/src/app/services/events.ts +++ b/src/app/services/events.ts @@ -202,10 +202,18 @@ export class CoreEventsProvider { export class CoreEvents extends makeSingleton(CoreEventsProvider) {} /** - * Data passed to session expired event. + * Data passed to SESSION_EXPIRED event. */ export type CoreEventSessionExpiredData = { pageName?: string; params?: Params; siteId?: string; }; + +/** + * Data passed to CORE_LOADING_CHANGED event. + */ +export type CoreEventLoadingChangedData = { + loaded: boolean; + uniqueId: string; +}; From 13ae736480d864efc961b7d07aed35f063b7b624 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Oct 2020 12:51:36 +0200 Subject: [PATCH 05/10] MOBILE-3565 directives: Implement auto-focus directive --- src/app/directives/auto-focus.ts | 75 +++++++++++++++++++++++++ src/app/directives/directives.module.ts | 3 + 2 files changed, 78 insertions(+) create mode 100644 src/app/directives/auto-focus.ts diff --git a/src/app/directives/auto-focus.ts b/src/app/directives/auto-focus.ts new file mode 100644 index 000000000..874691689 --- /dev/null +++ b/src/app/directives/auto-focus.ts @@ -0,0 +1,75 @@ +// (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 { Directive, Input, OnInit, ElementRef } from '@angular/core'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Directive to auto focus an element when a view is loaded. + * + * You can apply it conditionallity assigning it a boolean value: + */ +@Directive({ + selector: '[core-auto-focus]', +}) +export class CoreAutoFocusDirective implements OnInit { + + @Input('core-auto-focus') coreAutoFocus: boolean | string = true; + + protected element: HTMLElement; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // @todo + // if (this.navCtrl.isTransitioning()) { + // // Navigating to a new page. Wait for the transition to be over. + // const subscription = this.navCtrl.viewDidEnter.subscribe(() => { + // this.autoFocus(); + // subscription.unsubscribe(); + // }); + // } else { + this.autoFocus(); + // } + } + + /** + * Function after the view is initialized. + */ + protected autoFocus(): void { + const autoFocus = CoreUtils.instance.isTrueOrOne(this.coreAutoFocus); + if (autoFocus) { + // Wait a bit to make sure the view is loaded. + setTimeout(() => { + // If it's a ion-input or ion-textarea, search the right input to use. + let element = this.element; + if (this.element.tagName == 'ION-INPUT') { + element = this.element.querySelector('input') || element; + } else if (this.element.tagName == 'ION-TEXTAREA') { + element = this.element.querySelector('textarea') || element; + } + + CoreDomUtils.instance.focusElement(element); + }, 200); + } + } + +} diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts index 11fb1fa22..f6cd92f94 100644 --- a/src/app/directives/directives.module.ts +++ b/src/app/directives/directives.module.ts @@ -13,16 +13,19 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreAutoFocusDirective } from './auto-focus'; import { CoreFormatTextDirective } from './format-text'; import { CoreLongPressDirective } from './long-press.directive'; @NgModule({ declarations: [ + CoreAutoFocusDirective, CoreFormatTextDirective, CoreLongPressDirective, ], imports: [], exports: [ + CoreAutoFocusDirective, CoreFormatTextDirective, CoreLongPressDirective, ], From 73612f51380aa6ee9c5044ae128758e0716aeba9 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Oct 2020 15:35:00 +0200 Subject: [PATCH 06/10] MOBILE-3565 directives: Implement supress-events directive --- src/app/directives/directives.module.ts | 4 + src/app/directives/supress-events.ts | 97 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/app/directives/supress-events.ts diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts index f6cd92f94..5e8a40358 100644 --- a/src/app/directives/directives.module.ts +++ b/src/app/directives/directives.module.ts @@ -13,21 +13,25 @@ // limitations under the License. import { NgModule } from '@angular/core'; + import { CoreAutoFocusDirective } from './auto-focus'; import { CoreFormatTextDirective } from './format-text'; import { CoreLongPressDirective } from './long-press.directive'; +import { CoreSupressEventsDirective } from './supress-events'; @NgModule({ declarations: [ CoreAutoFocusDirective, CoreFormatTextDirective, CoreLongPressDirective, + CoreSupressEventsDirective, ], imports: [], exports: [ CoreAutoFocusDirective, CoreFormatTextDirective, CoreLongPressDirective, + CoreSupressEventsDirective, ], }) export class CoreDirectivesModule {} diff --git a/src/app/directives/supress-events.ts b/src/app/directives/supress-events.ts new file mode 100644 index 000000000..0b3c363a2 --- /dev/null +++ b/src/app/directives/supress-events.ts @@ -0,0 +1,97 @@ +// (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. + +// Based on http://roblouie.com/article/198/using-gestures-in-the-ionic-2-beta/ + +import { Directive, ElementRef, OnInit, Input, Output, EventEmitter } from '@angular/core'; + +/** + * Directive to suppress all events on an element. This is useful to prevent keyboard closing when clicking this element. + * + * This directive is based on some code posted by johnthackstonanderson in + * https://github.com/ionic-team/ionic-plugin-keyboard/issues/81 + * + * @description + * + * If nothing is supplied or string 'all', then all the default events will be suppressed. This is the recommended usage. + * + * If you only want to suppress a single event just pass the name of the event. If you want to suppress a set of events, + * pass an array with the names of the events to suppress. + * + * Example usage: + * + * + */ +@Directive({ + selector: '[core-suppress-events]', +}) +export class CoreSupressEventsDirective implements OnInit { + + @Input('core-suppress-events') suppressEvents: string | string[]; + @Output() onClick = new EventEmitter(); // eslint-disable-line @angular-eslint/no-output-on-prefix + + protected element: HTMLElement; + + constructor(el: ElementRef) { + this.element = el.nativeElement; + } + + /** + * Initialize event listeners. + */ + ngOnInit(): void { + let events: string[]; + + if (this.suppressEvents == 'all' || typeof this.suppressEvents == 'undefined' || this.suppressEvents === null) { + // Suppress all events. + events = ['click', 'mousedown', 'touchdown', 'touchmove', 'touchstart']; + + } else if (typeof this.suppressEvents == 'string') { + // It's a string, just suppress this event. + events = [this.suppressEvents]; + + } else if (Array.isArray(this.suppressEvents)) { + // Array supplied. + events = this.suppressEvents; + } else { + events = []; + } + + // Suppress the events. + for (const evName of events) { + this.element.addEventListener(evName, this.stopBubble.bind(this)); + } + + // Now listen to "click" events. + this.element.addEventListener('mouseup', (event) => { // Triggered in Android & iOS. + this.onClick.emit(event); + }); + + this.element.addEventListener('touchend', (event) => { // Triggered desktop & browser. + this.stopBubble(event); + this.onClick.emit(event); + }); + } + + /** + * Stop event default and propagation. + * + * @param event Event. + */ + protected stopBubble(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + } + +} From 317444049c88b5ed90c9a53a2867867addbb06cb Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Oct 2020 15:37:16 +0200 Subject: [PATCH 07/10] MOBILE-3565 login: Initial implementation of credentials page --- src/app/components/components.module.ts | 10 +- src/app/core/login/login-routing.module.ts | 5 + src/app/core/login/login.module.ts | 4 + .../login/pages/credentials/credentials.html | 75 ++++ .../pages/credentials/credentials.page.ts | 326 ++++++++++++++++++ src/app/core/login/pages/site/site.page.ts | 5 + src/app/core/login/services/helper.ts | 6 +- src/app/services/ws.ts | 4 +- 8 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 src/app/core/login/pages/credentials/credentials.html create mode 100644 src/app/core/login/pages/credentials/credentials.page.ts diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index dfb7b2b51..afc95a694 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -13,11 +13,15 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; import { CoreIconComponent } from './icon/icon'; import { CoreLoadingComponent } from './loading/loading'; import { CoreShowPasswordComponent } from './show-password/show-password'; +import { CoreDirectivesModule } from '@app/directives/directives.module'; +import { CorePipesModule } from '@app/pipes/pipes.module'; @NgModule({ declarations: [ @@ -26,7 +30,11 @@ import { CoreShowPasswordComponent } from './show-password/show-password'; CoreShowPasswordComponent, ], imports: [ - IonicModule, + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + CoreDirectivesModule, + CorePipesModule, ], exports: [ CoreIconComponent, diff --git a/src/app/core/login/login-routing.module.ts b/src/app/core/login/login-routing.module.ts index f71d29648..775de336d 100644 --- a/src/app/core/login/login-routing.module.ts +++ b/src/app/core/login/login-routing.module.ts @@ -15,6 +15,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { CoreLoginCredentialsPage } from './pages/credentials/credentials.page'; import { CoreLoginInitPage } from './pages/init/init.page'; import { CoreLoginSitePage } from './pages/site/site.page'; @@ -27,6 +28,10 @@ const routes: Routes = [ path: 'site', component: CoreLoginSitePage, }, + { + path: 'credentials', + component: CoreLoginCredentialsPage, + }, ]; @NgModule({ diff --git a/src/app/core/login/login.module.ts b/src/app/core/login/login.module.ts index f3e05da4d..0c18f5430 100644 --- a/src/app/core/login/login.module.ts +++ b/src/app/core/login/login.module.ts @@ -20,8 +20,10 @@ import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '@/app/components/components.module'; +import { CoreDirectivesModule } from '@/app/directives/directives.module'; import { CoreLoginRoutingModule } from './login-routing.module'; +import { CoreLoginCredentialsPage } from './pages/credentials/credentials.page'; import { CoreLoginInitPage } from './pages/init/init.page'; import { CoreLoginSitePage } from './pages/site/site.page'; import { CoreLoginHelperProvider } from './services/helper'; @@ -35,8 +37,10 @@ import { CoreLoginHelperProvider } from './services/helper'; FormsModule, ReactiveFormsModule, CoreComponentsModule, + CoreDirectivesModule, ], declarations: [ + CoreLoginCredentialsPage, CoreLoginInitPage, CoreLoginSitePage, ], diff --git a/src/app/core/login/pages/credentials/credentials.html b/src/app/core/login/pages/credentials/credentials.html new file mode 100644 index 000000000..13d91cb9d --- /dev/null +++ b/src/app/core/login/pages/credentials/credentials.html @@ -0,0 +1,75 @@ + + + + + + + {{ 'core.login.login' | translate }} + + + + + + + + +
+ + +

+

{{siteUrl}}

+
+ + + + + + + + + +
+
diff --git a/src/app/core/login/pages/credentials/credentials.page.ts b/src/app/core/login/pages/credentials/credentials.page.ts new file mode 100644 index 000000000..57fa2913e --- /dev/null +++ b/src/app/core/login/pages/credentials/credentials.page.ts @@ -0,0 +1,326 @@ +// (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 { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NavController } from '@ionic/angular'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/helper'; +import CoreConfigConstants from '@app/config.json'; +import { Translate } from '@singletons/core.singletons'; +import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@/app/classes/site'; +import { CoreEvents, CoreEventsProvider } from '@/app/services/events'; + +/** + * Page that displays a "splash screen" while the app is being initialized. + */ +@Component({ + selector: 'page-core-login-credentials', + templateUrl: 'credentials.html', +}) +export class CoreLoginCredentialsPage implements OnInit, OnDestroy { + + @ViewChild('credentialsForm') formElement: ElementRef; + + credForm: FormGroup; + siteUrl: string; + siteChecked = false; + siteName: string; + logoUrl: string; + authInstructions: string; + canSignup: boolean; + identityProviders: CoreSiteIdentityProvider[]; + pageLoaded = false; + isBrowserSSO = false; + isFixedUrlSet = false; + showForgottenPassword = true; + showScanQR: boolean; + + protected siteConfig: CoreSitePublicConfigResponse; + protected eventThrown = false; + protected viewLeft = false; + protected siteId: string; + protected urlToOpen: string; + + constructor( + protected fb: FormBuilder, + protected route: ActivatedRoute, + protected navCtrl: NavController, + ) { + + const canScanQR = CoreUtils.instance.canScanQR(); + if (canScanQR) { + if (typeof CoreConfigConstants['displayqroncredentialscreen'] == 'undefined') { + this.showScanQR = CoreLoginHelper.instance.isFixedUrlSet(); + } else { + this.showScanQR = !!CoreConfigConstants['displayqroncredentialscreen']; + } + } else { + this.showScanQR = false; + } + } + + /** + * Initialize the component. + */ + ngOnInit(): void { + this.route.queryParams.subscribe(params => { + this.siteUrl = params['siteUrl']; + this.siteName = params['siteName'] || null; + this.logoUrl = !CoreConfigConstants.forceLoginLogo && params['logoUrl'] || null; + this.siteConfig = params['siteConfig']; + this.urlToOpen = params['urlToOpen']; + + this.credForm = this.fb.group({ + username: [params['username'] || '', Validators.required], + password: ['', Validators.required], + }); + }); + + this.treatSiteConfig(); + this.isFixedUrlSet = CoreLoginHelper.instance.isFixedUrlSet(); + + if (this.isFixedUrlSet) { + // Fixed URL, we need to check if it uses browser SSO login. + this.checkSite(this.siteUrl); + } else { + this.siteChecked = true; + this.pageLoaded = true; + } + } + + /** + * Check if a site uses local_mobile, requires SSO login, etc. + * This should be used only if a fixed URL is set, otherwise this check is already performed in CoreLoginSitePage. + * + * @param siteUrl Site URL to check. + * @return Promise resolved when done. + */ + protected async checkSite(siteUrl: string): Promise { + this.pageLoaded = false; + + // If the site is configured with http:// protocol we force that one, otherwise we use default mode. + const protocol = siteUrl.indexOf('http://') === 0 ? 'http://' : undefined; + + try { + const result = await CoreSites.instance.checkSite(siteUrl, protocol); + + this.siteChecked = true; + this.siteUrl = result.siteUrl; + + this.siteConfig = result.config; + this.treatSiteConfig(); + + if (result && result.warning) { + CoreDomUtils.instance.showErrorModal(result.warning, true, 4000); + } + + if (CoreLoginHelper.instance.isSSOLoginNeeded(result.code)) { + // SSO. User needs to authenticate in a browser. + this.isBrowserSSO = true; + + // Check that there's no SSO authentication ongoing and the view hasn't changed. + if (!CoreApp.instance.isSSOAuthenticationOngoing() && !this.viewLeft) { + CoreLoginHelper.instance.confirmAndOpenBrowserForSSOLogin( + result.siteUrl, result.code, result.service, result.config?.launchurl); + } + } else { + this.isBrowserSSO = false; + } + + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + this.pageLoaded = true; + } + } + + /** + * Treat the site configuration (if it exists). + */ + protected treatSiteConfig(): void { + if (this.siteConfig) { + this.siteName = CoreConfigConstants.sitename ? CoreConfigConstants.sitename : this.siteConfig.sitename; + this.logoUrl = CoreLoginHelper.instance.getLogoUrl(this.siteConfig); + this.authInstructions = this.siteConfig.authinstructions || Translate.instance.instant('core.login.loginsteps'); + + const disabledFeatures = CoreLoginHelper.instance.getDisabledFeatures(this.siteConfig); + this.identityProviders = CoreLoginHelper.instance.getValidIdentityProviders(this.siteConfig, disabledFeatures); + this.canSignup = this.siteConfig.registerauth == 'email' && + !CoreLoginHelper.instance.isEmailSignupDisabled(this.siteConfig, disabledFeatures); + this.showForgottenPassword = !CoreLoginHelper.instance.isForgottenPasswordDisabled(this.siteConfig, disabledFeatures); + + if (!this.eventThrown && !this.viewLeft) { + this.eventThrown = true; + CoreEvents.instance.trigger(CoreEventsProvider.LOGIN_SITE_CHECKED, { config: this.siteConfig }); + } + } else { + this.authInstructions = null; + this.canSignup = false; + this.identityProviders = []; + } + } + + /** + * Tries to authenticate the user. + * + * @param e Event. + * @return Promise resolved when done. + */ + async login(e?: Event): Promise { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + CoreApp.instance.closeKeyboard(); + + // Get input data. + const siteUrl = this.siteUrl; + const username = this.credForm.value.username; + const password = this.credForm.value.password; + + if (!this.siteChecked || this.isBrowserSSO) { + // Site wasn't checked (it failed) or a previous check determined it was SSO. Let's check again. + await this.checkSite(siteUrl); + + if (!this.isBrowserSSO) { + // Site doesn't use browser SSO, throw app's login again. + return this.login(); + } + + return; + } + + if (!username) { + CoreDomUtils.instance.showErrorModal('core.login.usernamerequired', true); + + return; + } + if (!password) { + CoreDomUtils.instance.showErrorModal('core.login.passwordrequired', true); + + return; + } + + if (!CoreApp.instance.isOnline()) { + CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true); + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading(); + + // Start the authentication process. + try { + const data = await CoreSites.instance.getUserToken(siteUrl, username, password); + + const id = await CoreSites.instance.newSite(data.siteUrl, data.token, data.privateToken); + + // Reset fields so the data is not in the view anymore. + this.credForm.controls['username'].reset(); + this.credForm.controls['password'].reset(); + + this.siteId = id; + + await CoreLoginHelper.instance.goToSiteInitialPage(undefined, undefined, undefined, undefined, this.urlToOpen); + } catch (error) { + CoreLoginHelper.instance.treatUserTokenError(siteUrl, error, username, password); + + if (error.loggedout) { + // @todo Go to sites page. + } else if (error.errorcode == 'forcepasswordchangenotice') { + // Reset password field. + this.credForm.controls.password.reset(); + } + } finally { + modal.dismiss(); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); + } + } + + /** + * Forgotten password button clicked. + */ + forgottenPassword(): void { + CoreLoginHelper.instance.forgottenPasswordClicked( + this.navCtrl, this.siteUrl, this.credForm.value.username, this.siteConfig); + } + + /** + * An OAuth button was clicked. + * + * @param provider The provider that was clicked. + */ + oauthClicked(provider: CoreSiteIdentityProvider): void { + if (!CoreLoginHelper.instance.openBrowserForOAuthLogin(this.siteUrl, provider, this.siteConfig.launchurl)) { + CoreDomUtils.instance.showErrorModal('Invalid data.'); + } + } + + /** + * Signup button was clicked. + */ + signup(): void { + // @todo Go to signup. + } + + /** + * Show instructions and scan QR code. + */ + showInstructionsAndScanQR(): void { + // Show some instructions first. + CoreDomUtils.instance.showAlertWithOptions({ + header: Translate.instance.instant('core.login.faqwhereisqrcode'), + message: Translate.instance.instant('core.login.faqwhereisqrcodeanswer', + { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }), + buttons: [ + { + text: Translate.instance.instant('core.cancel'), + role: 'cancel', + }, + { + text: Translate.instance.instant('core.next'), + handler: (): void => { + this.scanQR(); + }, + }, + ], + }); + } + + /** + * Scan a QR code and put its text in the URL input. + * + * @return Promise resolved when done. + */ + async scanQR(): Promise { + // @todo Scan for a QR code. + } + + /** + * View destroyed. + */ + ngOnDestroy(): void { + this.viewLeft = true; + CoreEvents.instance.trigger(CoreEventsProvider.LOGIN_SITE_UNCHECKED, { config: this.siteConfig }, this.siteId); + } + +} diff --git a/src/app/core/login/pages/site/site.page.ts b/src/app/core/login/pages/site/site.page.ts index 8183d1e68..465181352 100644 --- a/src/app/core/login/pages/site/site.page.ts +++ b/src/app/core/login/pages/site/site.page.ts @@ -28,6 +28,7 @@ import CoreConfigConstants from '@app/config.json'; import { Translate } from '@singletons/core.singletons'; import { CoreUrl } from '@singletons/url'; import { CoreUrlUtils } from '@/app/services/utils/url'; +import { NavController } from '@ionic/angular'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -58,6 +59,7 @@ export class CoreLoginSitePage implements OnInit { constructor( protected route: ActivatedRoute, protected formBuilder: FormBuilder, + protected navCtrl: NavController, ) { let url = ''; @@ -368,6 +370,9 @@ export class CoreLoginSitePage implements OnInit { } // @todo Navigate to credentials. + this.navCtrl.navigateForward('/login/credentials', { + queryParams: pageParams, + }); } } diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts index c37c2807c..a4256866e 100644 --- a/src/app/core/login/services/helper.ts +++ b/src/app/core/login/services/helper.ts @@ -14,7 +14,7 @@ import { Injectable } from '@angular/core'; import { Location } from '@angular/common'; -import { Router, Params } from '@angular/router'; +import { Params } from '@angular/router'; import { NavController } from '@ionic/angular'; import { Md5 } from 'ts-md5/dist/md5'; @@ -55,7 +55,6 @@ export class CoreLoginHelperProvider { waitingForBrowser = false; constructor( - protected router: Router, private location: Location, ) { this.logger = CoreLogger.getInstance('CoreLoginHelper'); @@ -162,6 +161,7 @@ export class CoreLoginHelperProvider { * @param siteConfig Site config. */ async forgottenPasswordClicked( + navCtrl: NavController, siteUrl: string, username: string, siteConfig?: CoreSitePublicConfigResponse, @@ -180,7 +180,7 @@ export class CoreLoginHelperProvider { const canReset = await this.canRequestPasswordReset(siteUrl); if (canReset) { - await this.router.navigate(['/login/forgottenpassword'], { + await navCtrl.navigateForward(['/login/forgottenpassword'], { queryParams: { siteUrl, username, diff --git a/src/app/services/ws.ts b/src/app/services/ws.ts index 51145b979..a97eccea7 100644 --- a/src/app/services/ws.ts +++ b/src/app/services/ws.ts @@ -113,9 +113,9 @@ export class CoreWSProvider { if (this.retryCalls.length > 0) { this.logger.warn('Calls locked, trying later...'); - return this.addToRetryQueue(method, siteUrl, data, preSets); + return this.addToRetryQueue(method, siteUrl, dataToSend, preSets); } else { - return this.performPost(method, siteUrl, data, preSets); + return this.performPost(method, siteUrl, dataToSend, preSets); } } From 7a517ba271f690df95b7ef61317061d82aa1b5a4 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 21 Oct 2020 16:32:27 +0200 Subject: [PATCH 08/10] MOBILE-3565 core: Fix errors caused by strict checks --- src/app/classes/delegate.ts | 20 +- src/app/classes/errors/ajaxwserror.ts | 2 +- src/app/classes/errors/wserror.ts | 1 + src/app/classes/interceptor.ts | 4 +- src/app/classes/ion-loading.ts | 2 +- src/app/classes/queue-runner.ts | 2 +- src/app/classes/site.ts | 494 ++++---- src/app/classes/sqlitedb.ts | 4 +- src/app/components/icon/icon.ts | 4 +- src/app/components/loading/loading.ts | 4 +- .../components/show-password/show-password.ts | 16 +- src/app/core/constants.ts | 2 +- src/app/core/emulator/classes/sqlitedb.ts | 6 + .../pages/credentials/credentials.page.ts | 48 +- src/app/core/login/pages/init/init.page.ts | 66 +- src/app/core/login/pages/site/site.page.ts | 51 +- src/app/core/login/services/helper.ts | 70 +- src/app/directives/format-text.ts | 98 +- src/app/directives/supress-events.ts | 2 +- src/app/services/app.ts | 13 +- src/app/services/cron.ts | 105 +- src/app/services/file-helper.ts | 192 ++-- src/app/services/file-session.ts | 2 +- src/app/services/file.ts | 232 ++-- src/app/services/filepool.ts | 1020 ++++++++++------- src/app/services/local-notifications.ts | 123 +- src/app/services/plugin-file-delegate.ts | 4 +- src/app/services/sites.ts | 634 +++++----- src/app/services/utils/dom.ts | 277 +++-- src/app/services/utils/iframe.ts | 2 +- src/app/services/utils/mimetype.ts | 2 +- src/app/services/utils/text.ts | 4 +- src/app/services/ws.ts | 193 ++-- src/app/singletons/array.ts | 2 +- src/app/singletons/locutus.ts | 4 +- src/app/singletons/url.ts | 6 +- src/app/singletons/window.ts | 4 +- 37 files changed, 2100 insertions(+), 1615 deletions(-) diff --git a/src/app/classes/delegate.ts b/src/app/classes/delegate.ts index a3c9665fa..48a86308c 100644 --- a/src/app/classes/delegate.ts +++ b/src/app/classes/delegate.ts @@ -40,18 +40,18 @@ export class CoreDelegate { /** * Default handler */ - protected defaultHandler: CoreDelegateHandler; + protected defaultHandler?: CoreDelegateHandler; /** * Time when last updateHandler functions started. */ - protected lastUpdateHandlersStart: number; + protected lastUpdateHandlersStart = 0; /** * Feature prefix to check is feature is enabled or disabled in site. * This check is only made if not false. Override on the subclass or override isFeatureDisabled function. */ - protected featurePrefix: string; + protected featurePrefix?: string; /** * Name of the property to be used to index the handlers. By default, the handler's name will be used. @@ -78,7 +78,7 @@ export class CoreDelegate { /** * Function to resolve the handlers init promise. */ - protected handlersInitResolve: () => void; + protected handlersInitResolve!: () => void; /** * Constructor of the Delegate. @@ -110,7 +110,7 @@ export class CoreDelegate { * @param params Parameters to pass to the function. * @return Function returned value or default value. */ - protected executeFunctionOnEnabled(handlerName: string, fnName: string, params?: unknown[]): T { + protected executeFunctionOnEnabled(handlerName: string, fnName: string, params?: unknown[]): T | undefined { return this.execute(this.enabledHandlers[handlerName], fnName, params); } @@ -123,7 +123,7 @@ export class CoreDelegate { * @param params Parameters to pass to the function. * @return Function returned value or default value. */ - protected executeFunction(handlerName: string, fnName: string, params?: unknown[]): T { + protected executeFunction(handlerName: string, fnName: string, params?: unknown[]): T | undefined { return this.execute(this.handlers[handlerName], fnName, params); } @@ -136,7 +136,7 @@ export class CoreDelegate { * @param params Parameters to pass to the function. * @return Function returned value or default value. */ - private execute(handler: CoreDelegateHandler, fnName: string, params?: unknown[]): T { + private execute(handler: CoreDelegateHandler, fnName: string, params?: unknown[]): T | undefined { if (handler && handler[fnName]) { return handler[fnName].apply(handler, params); } else if (this.defaultHandler && this.defaultHandler[fnName]) { @@ -252,7 +252,7 @@ export class CoreDelegate { this.updatePromises[siteId] = {}; } - if (!CoreSites.instance.isLoggedIn() || this.isFeatureDisabled(handler, currentSite)) { + if (!CoreSites.instance.isLoggedIn() || this.isFeatureDisabled(handler, currentSite!)) { promise = Promise.resolve(false); } else { promise = Promise.resolve(handler.isEnabled()).catch(() => false); @@ -270,6 +270,8 @@ export class CoreDelegate { delete this.enabledHandlers[key]; } } + + return; }).finally(() => { // Update finished, delete the promise. delete this.updatePromises[siteId][handler.name]; @@ -295,7 +297,7 @@ export class CoreDelegate { * @return Resolved when done. */ protected async updateHandlers(): Promise { - const promises = []; + const promises: Promise[] = []; const now = Date.now(); this.logger.debug('Updating handlers for current site.'); diff --git a/src/app/classes/errors/ajaxwserror.ts b/src/app/classes/errors/ajaxwserror.ts index c2e5df5c3..338a0706a 100644 --- a/src/app/classes/errors/ajaxwserror.ts +++ b/src/app/classes/errors/ajaxwserror.ts @@ -28,7 +28,7 @@ export class CoreAjaxWSError extends CoreError { backtrace?: string; // Backtrace. Only if debug mode is enabled. available?: number; // Whether the AJAX call is available. 0 if unknown, 1 if available, -1 if not available. - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any constructor(error: any, available?: number) { super(error.message); diff --git a/src/app/classes/errors/wserror.ts b/src/app/classes/errors/wserror.ts index 27e3df43f..a3b4c9c0f 100644 --- a/src/app/classes/errors/wserror.ts +++ b/src/app/classes/errors/wserror.ts @@ -27,6 +27,7 @@ export class CoreWSError extends CoreError { debuginfo?: string; // Debug info. Only if debug mode is enabled. backtrace?: string; // Backtrace. Only if debug mode is enabled. + // eslint-disable-next-line @typescript-eslint/no-explicit-any constructor(error: any) { super(error.message); diff --git a/src/app/classes/interceptor.ts b/src/app/classes/interceptor.ts index c7fcaa782..34c8e4f4d 100644 --- a/src/app/classes/interceptor.ts +++ b/src/app/classes/interceptor.ts @@ -30,7 +30,7 @@ export class CoreInterceptor implements HttpInterceptor { * @param addNull Add null values to the serialized as empty parameters. * @return Serialization of the object. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any static serialize(obj: any, addNull?: boolean): string { let query = ''; @@ -61,7 +61,7 @@ export class CoreInterceptor implements HttpInterceptor { return query.length ? query.substr(0, query.length - 1) : query; } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any intercept(req: HttpRequest, next: HttpHandler): Observable { // Add the header and serialize the body if needed. const newReq = req.clone({ diff --git a/src/app/classes/ion-loading.ts b/src/app/classes/ion-loading.ts index 370b9fa25..450e0aced 100644 --- a/src/app/classes/ion-loading.ts +++ b/src/app/classes/ion-loading.ts @@ -25,7 +25,7 @@ export class CoreIonLoadingElement { constructor(public loading: HTMLIonLoadingElement) { } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any async dismiss(data?: any, role?: string): Promise { if (!this.isPresented || this.isDismissed) { this.isDismissed = true; diff --git a/src/app/classes/queue-runner.ts b/src/app/classes/queue-runner.ts index 3b05b1369..fb70f70b9 100644 --- a/src/app/classes/queue-runner.ts +++ b/src/app/classes/queue-runner.ts @@ -91,7 +91,7 @@ export class CoreQueueRunner { return; } - const item = this.orderedQueue.shift(); + const item = this.orderedQueue.shift()!; this.numberRunning++; try { diff --git a/src/app/classes/site.ts b/src/app/classes/site.ts index c02a3259f..7ff9b058d 100644 --- a/src/app/classes/site.ts +++ b/src/app/classes/site.ts @@ -32,12 +32,15 @@ import { CoreError } from '@classes/errors/error'; import { CoreWSError } from '@classes/errors/wserror'; import { CoreLogger } from '@singletons/logger'; import { Translate } from '@singletons/core.singletons'; +import { CoreIonLoadingElement } from './ion-loading'; /** * Class that represents a site (combination of site + user). * It will have all the site data and provide utility functions regarding a site. * To add tables to the site's database, please use CoreSitesProvider.registerSiteSchema. This will make sure that * the tables are created in all the sites, not just the current one. + * + * @todo: Refactor this class to improve "temporary" sites support (not fully authenticated). */ export class CoreSite { @@ -78,17 +81,17 @@ export class CoreSite { // Rest of variables. protected logger: CoreLogger; - protected db: SQLiteDB; + protected db?: SQLiteDB; protected cleanUnicode = false; protected lastAutoLogin = 0; protected offlineDisabled = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any protected ongoingRequests: { [cacheId: string]: Promise } = {}; protected requestQueue: RequestQueueItem[] = []; - protected requestQueueTimeout: number = null; - protected tokenPluginFileWorks: boolean; - protected tokenPluginFileWorksPromise: Promise; - protected oauthId: number; + protected requestQueueTimeout: number | null = null; + protected tokenPluginFileWorks?: boolean; + protected tokenPluginFileWorksPromise?: Promise; + protected oauthId?: number; /** * Create a site. @@ -101,9 +104,16 @@ export class CoreSite { * @param config Site public config. * @param loggedOut Whether user is logged out. */ - constructor(public id: string, public siteUrl: string, public token?: string, public infos?: CoreSiteInfo, - public privateToken?: string, public config?: CoreSiteConfig, public loggedOut?: boolean) { - this.logger = CoreLogger.getInstance('CoreWSProvider'); + constructor( + public id: string | undefined, + public siteUrl: string, + public token?: string, + public infos?: CoreSiteInfo, + public privateToken?: string, + public config?: CoreSiteConfig, + public loggedOut?: boolean, + ) { + this.logger = CoreLogger.getInstance('CoreSite'); this.setInfo(infos); this.calculateOfflineDisabled(); @@ -125,6 +135,11 @@ export class CoreSite { * @return Site ID. */ getId(): string { + if (!this.id) { + // Shouldn't happen for authenticated sites. + throw new CoreError('This site doesn\'t have an ID'); + } + return this.id; } @@ -143,6 +158,11 @@ export class CoreSite { * @return Site token. */ getToken(): string { + if (!this.token) { + // Shouldn't happen for authenticated sites. + throw new CoreError('This site doesn\'t have a token'); + } + return this.token; } @@ -151,7 +171,7 @@ export class CoreSite { * * @return Site info. */ - getInfo(): CoreSiteInfo { + getInfo(): CoreSiteInfo | undefined { return this.infos; } @@ -160,7 +180,7 @@ export class CoreSite { * * @return Site private token. */ - getPrivateToken(): string { + getPrivateToken(): string | undefined { return this.privateToken; } @@ -170,6 +190,11 @@ export class CoreSite { * @return Site DB. */ getDb(): SQLiteDB { + if (!this.db) { + // Shouldn't happen for authenticated sites. + throw new CoreError('Site DB doesn\'t exist'); + } + return this.db; } @@ -179,9 +204,12 @@ export class CoreSite { * @return User's ID. */ getUserId(): number { - if (typeof this.infos != 'undefined' && typeof this.infos.userid != 'undefined') { - return this.infos.userid; + if (!this.infos) { + // Shouldn't happen for authenticated sites. + throw new CoreError('Site info could not be fetched.'); } + + return this.infos.userid; } /** @@ -190,7 +218,7 @@ export class CoreSite { * @return Site Home ID. */ getSiteHomeId(): number { - return this.infos && this.infos.siteid || 1; + return this.infos?.siteid || 1; } /** @@ -203,7 +231,7 @@ export class CoreSite { // Overridden by config. return CoreConfigConstants.sitename; } else { - return this.infos && this.infos.sitename || ''; + return this.infos?.sitename || ''; } } @@ -249,7 +277,7 @@ export class CoreSite { * * @return OAuth ID. */ - getOAuthId(): number { + getOAuthId(): number | undefined { return this.oauthId; } @@ -258,14 +286,14 @@ export class CoreSite { * * @param New info. */ - setInfo(infos: CoreSiteInfo): void { + setInfo(infos?: CoreSiteInfo): void { this.infos = infos; // Index function by name to speed up wsAvailable method. - if (infos && infos.functions) { + if (infos?.functions) { infos.functionsByName = {}; infos.functions.forEach((func) => { - infos.functionsByName[func.name] = func; + infos.functionsByName![func.name] = func; }); } } @@ -298,7 +326,7 @@ export class CoreSite { * * @param oauth OAuth ID. */ - setOAuthId(oauthId: number): void { + setOAuthId(oauthId: number | undefined): void { this.oauthId = oauthId; } @@ -317,9 +345,9 @@ export class CoreSite { * @return Whether can access my files. */ canAccessMyFiles(): boolean { - const infos = this.getInfo(); + const info = this.getInfo(); - return infos && (typeof infos.usercanmanageownfiles === 'undefined' || infos.usercanmanageownfiles); + return !!(info && (typeof info.usercanmanageownfiles === 'undefined' || info.usercanmanageownfiles)); } /** @@ -328,9 +356,9 @@ export class CoreSite { * @return Whether can download files. */ canDownloadFiles(): boolean { - const infos = this.getInfo(); + const info = this.getInfo(); - return infos && infos.downloadfiles > 0; + return !!info?.downloadfiles && info?.downloadfiles > 0; } /** @@ -340,22 +368,20 @@ export class CoreSite { * @param whenUndefined The value to return when the parameter is undefined. * @return Whether can use advanced feature. */ - canUseAdvancedFeature(feature: string, whenUndefined: boolean = true): boolean { - const infos = this.getInfo(); - let canUse = true; + canUseAdvancedFeature(featureName: string, whenUndefined: boolean = true): boolean { + const info = this.getInfo(); - if (typeof infos.advancedfeatures === 'undefined') { - canUse = whenUndefined; - } else { - for (const i in infos.advancedfeatures) { - const item = infos.advancedfeatures[i]; - if (item.name === feature && item.value === 0) { - canUse = false; - } - } + if (typeof info?.advancedfeatures === 'undefined') { + return whenUndefined; } - return canUse; + const feature = info.advancedfeatures.find((item) => item.name === featureName); + + if (!feature) { + return whenUndefined; + } + + return feature.value !== 0; } /** @@ -364,9 +390,9 @@ export class CoreSite { * @return Whether can upload files. */ canUploadFiles(): boolean { - const infos = this.getInfo(); + const info = this.getInfo(); - return infos && infos.uploadfiles > 0; + return !!info?.uploadfiles && info?.uploadfiles > 0; } /** @@ -396,7 +422,7 @@ export class CoreSite { * @param preSets Extra options. * @return Promise resolved with the response, rejected with CoreWSError if it fails. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any read(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise { preSets = preSets || {}; if (typeof preSets.getFromCache == 'undefined') { @@ -420,7 +446,7 @@ export class CoreSite { * @param preSets Extra options. * @return Promise resolved with the response, rejected with CoreWSError if it fails. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any write(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise { preSets = preSets || {}; if (typeof preSets.getFromCache == 'undefined') { @@ -455,13 +481,13 @@ export class CoreSite { * This method is smart which means that it will try to map the method to a compatibility one if need be, usually this * means that it will fallback on the 'local_mobile_' prefixed function if it is available and the non-prefixed is not. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any - request(method: string, data: any, preSets: CoreSiteWSPreSets, retrying?: boolean): Promise { - const initialToken = this.token; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async request(method: string, data: any, preSets: CoreSiteWSPreSets, retrying?: boolean): Promise { + const initialToken = this.token || ''; data = data || {}; if (!CoreApp.instance.isOnline() && this.offlineDisabled) { - return Promise.reject(new CoreError(Translate.instance.instant('core.errorofflinedisabled'))); + throw new CoreError(Translate.instance.instant('core.errorofflinedisabled')); } // Check if the method is available, use a prefixed version if possible. @@ -474,12 +500,12 @@ export class CoreSite { } else { this.logger.error(`WS function '${method}' is not available, even in compatibility mode.`); - return Promise.reject(new CoreError(Translate.instance.instant('core.wsfunctionnotavailable'))); + throw new CoreError(Translate.instance.instant('core.wsfunctionnotavailable')); } } const wsPreSets: CoreWSPreSets = { - wsToken: this.token, + wsToken: this.token || '', siteUrl: this.siteUrl, cleanUnicode: this.cleanUnicode, typeExpected: preSets.typeExpected, @@ -509,24 +535,25 @@ export class CoreSite { data = CoreWS.instance.convertValuesToString(data, wsPreSets.cleanUnicode); if (data == null) { // Empty cleaned text found. - return Promise.reject(new CoreError(Translate.instance.instant('core.unicodenotsupportedcleanerror'))); + throw new CoreError(Translate.instance.instant('core.unicodenotsupportedcleanerror')); } const cacheId = this.getCacheId(method, data); // Check for an ongoing identical request if we're not ignoring cache. if (preSets.getFromCache && this.ongoingRequests[cacheId]) { - return this.ongoingRequests[cacheId].then((response) => - // Clone the data, this may prevent errors if in the callback the object is modified. - CoreUtils.instance.clone(response), - ); + const response = await this.ongoingRequests[cacheId]; + + // Clone the data, this may prevent errors if in the callback the object is modified. + return CoreUtils.instance.clone(response); } const promise = this.getFromCache(method, data, preSets, false).catch(() => { if (preSets.forceOffline) { // Don't call the WS, just fail. - return Promise.reject(new CoreError(Translate.instance.instant('core.cannotconnect', - { $a: CoreSite.MINIMUM_MOODLE_VERSION }))); + throw new CoreError( + Translate.instance.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + ); } // Call the WS. @@ -558,7 +585,7 @@ export class CoreSite { CoreEvents.instance.trigger(CoreEventsProvider.USER_DELETED, { params: data }, this.id); error.message = Translate.instance.instant('core.userdeleted'); - return Promise.reject(new CoreWSError(error)); + throw new CoreWSError(error); } else if (error.errorcode === 'forcepasswordchangenotice') { // Password Change Forced, trigger event. Try to get data from cache, the event will handle the error. CoreEvents.instance.trigger(CoreEventsProvider.PASSWORD_CHANGE_FORCED, {}, this.id); @@ -572,7 +599,7 @@ export class CoreSite { CoreEvents.instance.trigger(CoreEventsProvider.SITE_POLICY_NOT_AGREED, {}, this.id); error.message = Translate.instance.instant('core.login.sitepolicynotagreederror'); - return Promise.reject(new CoreWSError(error)); + throw new CoreWSError(error); } else if (error.errorcode === 'dmlwriteexception' && CoreTextUtils.instance.hasUnicodeData(data)) { if (!this.cleanUnicode) { // Try again cleaning unicode. @@ -583,7 +610,7 @@ export class CoreSite { // This should not happen. error.message = Translate.instance.instant('core.unicodenotsupported'); - return Promise.reject(new CoreWSError(error)); + throw new CoreWSError(error); } else if (error.exception === 'required_capability_exception' || error.errorcode === 'nopermission' || error.errorcode === 'notingroup') { // Translate error messages with missing strings. @@ -596,16 +623,16 @@ export class CoreSite { // Save the error instead of deleting the cache entry so the same content is displayed in offline. this.saveToCache(method, data, error, preSets); - return Promise.reject(new CoreWSError(error)); + throw new CoreWSError(error); } else if (preSets.cacheErrors && preSets.cacheErrors.indexOf(error.errorcode) != -1) { // Save the error instead of deleting the cache entry so the same content is displayed in offline. this.saveToCache(method, data, error, preSets); - return Promise.reject(new CoreWSError(error)); + throw new CoreWSError(error); } else if (typeof preSets.emergencyCache !== 'undefined' && !preSets.emergencyCache) { this.logger.debug(`WS call '${method}' failed. Emergency cache is forbidden, rejecting.`); - return Promise.reject(new CoreWSError(error)); + throw new CoreWSError(error); } if (preSets.deleteCacheIfWSError && CoreUtils.instance.isWebServiceError(error)) { @@ -614,7 +641,7 @@ export class CoreSite { // Ignore errors. }); - return Promise.reject(new CoreWSError(error)); + throw new CoreWSError(error); } this.logger.debug(`WS call '${method}' failed. Trying to use the emergency cache.`); @@ -623,11 +650,11 @@ export class CoreSite { return this.getFromCache(method, data, preSets, true).catch(() => Promise.reject(new CoreWSError(error))); }); - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any }).then((response: any) => { // Check if the response is an error, this happens if the error was stored in the cache. if (response && (typeof response.exception != 'undefined' || typeof response.errorcode != 'undefined')) { - return Promise.reject(new CoreWSError(response)); + throw new CoreWSError(response); } return response; @@ -636,15 +663,17 @@ export class CoreSite { this.ongoingRequests[cacheId] = promise; // Clear ongoing request after setting the promise (just in case it's already resolved). - return promise.finally(() => { + try { + const response = await promise; + + // We pass back a clone of the original object, this may prevent errors if in the callback the object is modified. + return CoreUtils.instance.clone(response); + } finally { // Make sure we don't clear the promise of a newer request that ignores the cache. if (this.ongoingRequests[cacheId] === promise) { delete this.ongoingRequests[cacheId]; } - }).then((response) => - // We pass back a clone of the original object, this may prevent errors if in the callback the object is modified. - CoreUtils.instance.clone(response), - ); + } } /** @@ -658,8 +687,7 @@ export class CoreSite { */ protected callOrEnqueueRequest( method: string, - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any - data: any, + data: any, // eslint-disable-line @typescript-eslint/no-explicit-any preSets: CoreSiteWSPreSets, wsPreSets: CoreWSPreSets, ): Promise { @@ -677,13 +705,13 @@ export class CoreSite { } } - const request: RequestQueueItem = { + const request: RequestQueueItem = { cacheId, method, data, preSets, wsPreSets, - deferred: CoreUtils.instance.promiseDefer(), + deferred: CoreUtils.instance.promiseDefer(), }; return this.enqueueRequest(request); @@ -695,8 +723,7 @@ export class CoreSite { * @param request The request to enqueue. * @return Promise resolved with the response when the WS is called. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any - protected enqueueRequest(request: RequestQueueItem): Promise { + protected enqueueRequest(request: RequestQueueItem): Promise { this.requestQueue.push(request); if (this.requestQueue.length >= CoreSite.REQUEST_QUEUE_LIMIT) { @@ -711,7 +738,7 @@ export class CoreSite { /** * Call the enqueued web service requests. */ - protected processRequestQueue(): void { + protected async processRequestQueue(): Promise { this.logger.debug(`Processing request queue (${this.requestQueue.length} requests)`); // Clear timeout if set. @@ -726,16 +753,18 @@ export class CoreSite { if (requests.length == 1 && !CoreSite.REQUEST_QUEUE_FORCE_WS) { // Only one request, do a regular web service call. - CoreWS.instance.call(requests[0].method, requests[0].data, requests[0].wsPreSets).then((data) => { + try { + const data = await CoreWS.instance.call(requests[0].method, requests[0].data, requests[0].wsPreSets); + requests[0].deferred.resolve(data); - }).catch((error) => { + } catch (error) { requests[0].deferred.reject(error); - }); + } return; } - const data = { + const requestsData = { requests: requests.map((request) => { const args = {}; const settings = {}; @@ -765,13 +794,18 @@ export class CoreSite { const wsPresets: CoreWSPreSets = { siteUrl: this.siteUrl, - wsToken: this.token, + wsToken: this.token || '', }; - CoreWS.instance.call('tool_mobile_call_external_functions', data, wsPresets) - .then((data) => { + try { + const data = await CoreWS.instance.call( + 'tool_mobile_call_external_functions', + requestsData, + wsPresets, + ); + if (!data || !data.responses) { - return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); + throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); } requests.forEach((request, i) => { @@ -781,9 +815,9 @@ export class CoreSite { // Request not executed, enqueue again. this.enqueueRequest(request); } else if (response.error) { - request.deferred.reject(CoreTextUtils.instance.parseJSON(response.exception)); + request.deferred.reject(CoreTextUtils.instance.parseJSON(response.exception || '')); } else { - let responseData = CoreTextUtils.instance.parseJSON(response.data); + let responseData = response.data ? CoreTextUtils.instance.parseJSON(response.data) : {}; // Match the behaviour of CoreWSProvider.call when no response is expected. const responseExpected = typeof wsPresets.responseExpected == 'undefined' || wsPresets.responseExpected; if (!responseExpected && (responseData == null || responseData === '')) { @@ -792,12 +826,12 @@ export class CoreSite { request.deferred.resolve(responseData); } }); - }).catch((error) => { + } catch (error) { // Error not specific to a single request, reject all promises. requests.forEach((request) => { request.deferred.reject(error); }); - }); + } } /** @@ -812,7 +846,7 @@ export class CoreSite { return false; } - if (this.infos.functionsByName[method]) { + if (this.infos?.functionsByName?.[method]) { return true; } @@ -831,7 +865,7 @@ export class CoreSite { * @param data Arguments to pass to the method. * @return Cache ID. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any protected getCacheId(method: string, data: any): string { return Md5.hashAsciiStr(method + ':' + CoreUtils.instance.sortAndStringify(data)); } @@ -846,66 +880,70 @@ export class CoreSite { * @param originalData Arguments to pass to the method before being converted to strings. * @return Promise resolved with the WS response. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any - protected getFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, emergency?: boolean): Promise { + protected async getFromCache( + method: string, + data: any, // eslint-disable-line @typescript-eslint/no-explicit-any + preSets: CoreSiteWSPreSets, + emergency?: boolean, + ): Promise { if (!this.db || !preSets.getFromCache) { - return Promise.reject(new CoreError('Get from cache is disabled.')); + throw new CoreError('Get from cache is disabled.'); } const id = this.getCacheId(method, data); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let promise: Promise; + let entry: CoreSiteWSCacheRecord | undefined; if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { - promise = this.db.getRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }).then((entries) => { - if (!entries.length) { - // Cache key not found, get by params sent. - return this.db.getRecord(CoreSite.WS_CACHE_TABLE, { id }); - } else if (entries.length > 1) { + const entries = await this.db.getRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }); + + if (!entries.length) { + // Cache key not found, get by params sent. + entry = await this.db!.getRecord(CoreSite.WS_CACHE_TABLE, { id }); + } else { + if (entries.length > 1) { // More than one entry found. Search the one with same ID as this call. - for (let i = 0, len = entries.length; i < len; i++) { - const entry = entries[i]; - if (entry.id == id) { - return entry; - } - } + entry = entries.find((entry) => entry.id == id); } - return entries[0]; - }); + if (!entry) { + entry = entries[0]; + } + } } else { - promise = this.db.getRecord(CoreSite.WS_CACHE_TABLE, { id }); + entry = await this.db!.getRecord(CoreSite.WS_CACHE_TABLE, { id }); } - return promise.then((entry) => { - const now = Date.now(); - let expirationTime: number; + if (typeof entry == 'undefined') { + throw new CoreError('Cache entry not valid.'); + } - preSets.omitExpires = preSets.omitExpires || preSets.forceOffline || !CoreApp.instance.isOnline(); + const now = Date.now(); + let expirationTime: number | undefined; - if (!preSets.omitExpires) { - expirationTime = entry.expirationTime + this.getExpirationDelay(preSets.updateFrequency); + preSets.omitExpires = preSets.omitExpires || preSets.forceOffline || !CoreApp.instance.isOnline(); - if (now > expirationTime) { - this.logger.debug('Cached element found, but it is expired'); + if (!preSets.omitExpires) { + expirationTime = entry.expirationTime + this.getExpirationDelay(preSets.updateFrequency); - return Promise.reject(new CoreError('Cache entry is expired.')); - } + if (now > expirationTime!) { + this.logger.debug('Cached element found, but it is expired'); + + throw new CoreError('Cache entry is expired.'); + } + } + + if (typeof entry.data != 'undefined') { + if (!expirationTime) { + this.logger.info(`Cached element found, id: ${id}. Expiration time ignored.`); + } else { + const expires = (expirationTime - now) / 1000; + this.logger.info(`Cached element found, id: ${id}. Expires in expires in ${expires} seconds`); } - if (typeof entry != 'undefined' && typeof entry.data != 'undefined') { - if (!expirationTime) { - this.logger.info(`Cached element found, id: ${id}. Expiration time ignored.`); - } else { - const expires = (expirationTime - now) / 1000; - this.logger.info(`Cached element found, id: ${id}. Expires in expires in ${expires} seconds`); - } + return CoreTextUtils.instance.parseJSON(entry.data, {}); + } - return CoreTextUtils.instance.parseJSON(entry.data, {}); - } - - return Promise.reject(new CoreError('Cache entry not valid.')); - }); + throw new CoreError('Cache entry not valid.'); } /** @@ -923,8 +961,10 @@ export class CoreSite { extraClause = ' AND componentId = ?'; } - const size = await this.db.getFieldSql('SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE + - ' WHERE component = ?' + extraClause, params); + const size = await this.getDb().getFieldSql( + 'SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE + ' WHERE component = ?' + extraClause, + params, + ); return size; } @@ -938,7 +978,7 @@ export class CoreSite { * @param preSets Extra options. * @return Promise resolved when the response is saved. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async saveToCache(method: string, data: any, response: any, preSets: CoreSiteWSPreSets): Promise { if (!this.db) { throw new CoreError('Site DB not initialized.'); @@ -981,7 +1021,7 @@ export class CoreSite { * @param allCacheKey True to delete all entries with the cache key, false to delete only by ID. * @return Promise resolved when the entries are deleted. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async deleteFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, allCacheKey?: boolean): Promise { if (!this.db) { throw new CoreError('Site DB not initialized.'); @@ -1042,7 +1082,7 @@ export class CoreSite { return CoreWS.instance.uploadFile(filePath, options, { siteUrl: this.siteUrl, - wsToken: this.token, + wsToken: this.token || '', }, onProgress); } @@ -1144,7 +1184,7 @@ export class CoreSite { const accessKey = this.tokenPluginFileWorks || typeof this.tokenPluginFileWorks == 'undefined' ? this.infos && this.infos.userprivateaccesskey : undefined; - return CoreUrlUtils.instance.fixPluginfileURL(url, this.token, this.siteUrl, accessKey); + return CoreUrlUtils.instance.fixPluginfileURL(url, this.token || '', this.siteUrl, accessKey); } /** @@ -1162,7 +1202,7 @@ export class CoreSite { * @return Promise to be resolved when the DB is deleted. */ async deleteFolder(): Promise { - if (!CoreFile.instance.isAvailable()) { + if (!CoreFile.instance.isAvailable() || !this.id) { return; } @@ -1178,7 +1218,7 @@ export class CoreSite { * @return Promise resolved with the site space usage (size). */ getSpaceUsage(): Promise { - if (CoreFile.instance.isAvailable()) { + if (CoreFile.instance.isAvailable() && this.id) { const siteFolderPath = CoreFile.instance.getSiteFolder(this.id); return CoreFile.instance.getDirectorySize(siteFolderPath).catch(() => 0); @@ -1195,7 +1235,7 @@ export class CoreSite { * @return Promise resolved with the total size of all data in the cache table (bytes) */ async getCacheUsage(): Promise { - const size = await this.db.getFieldSql('SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE); + const size = await this.getDb().getFieldSql('SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE); return size; } @@ -1219,7 +1259,7 @@ export class CoreSite { * @return Promise resolved with the Moodle docs URL. */ getDocsUrl(page?: string): Promise { - const release = this.infos.release ? this.infos.release : undefined; + const release = this.infos?.release ? this.infos.release : undefined; return CoreUrlUtils.instance.getDocsUrl(release, page); } @@ -1369,37 +1409,43 @@ export class CoreSite { * * @return Promise resolved with public config. Rejected with an object if error, see CoreWSProvider.callAjax. */ - getPublicConfig(): Promise { + async getPublicConfig(): Promise { const preSets: CoreWSAjaxPreSets = { siteUrl: this.siteUrl, }; - return CoreWS.instance.callAjax('tool_mobile_get_public_config', {}, preSets).catch((error) => { + let config: CoreSitePublicConfigResponse | undefined; + + try { + config = await CoreWS.instance.callAjax('tool_mobile_get_public_config', {}, preSets); + } catch (error) { if ((!this.getInfo() || this.isVersionGreaterEqualThan('3.8')) && error && error.errorcode == 'codingerror') { // This error probably means that there is a redirect in the site. Try to use a GET request. preSets.noLogin = true; preSets.useGet = true; - return CoreWS.instance.callAjax('tool_mobile_get_public_config', {}, preSets).catch((error2) => { + try { + config = await CoreWS.instance.callAjax('tool_mobile_get_public_config', {}, preSets); + } catch (error2) { if (this.getInfo() && this.isVersionGreaterEqualThan('3.8')) { // GET is supported, return the second error. - return Promise.reject(error2); + throw error2; } else { // GET not supported or we don't know if it's supported. Return first error. - return Promise.reject(error); + throw error; } - }); + } } - return Promise.reject(error); - }).then((config: CoreSitePublicConfigResponse) => { - // Use the wwwroot returned by the server. - if (config.httpswwwroot) { - this.siteUrl = config.httpswwwroot; - } + throw error; + } - return config; - }); + // Use the wwwroot returned by the server. + if (config!.httpswwwroot) { + this.siteUrl = config!.httpswwwroot; + } + + return config!; } /** @@ -1446,8 +1492,11 @@ export class CoreSite { * @param alertMessage If defined, an alert will be shown before opening the inappbrowser. * @return Promise resolved when done. */ - async openInAppWithAutoLoginIfSameSite(url: string, options?: InAppBrowserOptions, alertMessage?: string): - Promise { + async openInAppWithAutoLoginIfSameSite( + url: string, + options?: InAppBrowserOptions, + alertMessage?: string, + ): Promise { const iabInstance = await this.openWithAutoLoginIfSameSite(true, url, options, alertMessage); return iabInstance; @@ -1462,34 +1511,33 @@ export class CoreSite { * @param alertMessage If defined, an alert will be shown before opening the browser/inappbrowser. * @return Promise resolved when done. Resolve param is returned only if inApp=true. */ - openWithAutoLogin(inApp: boolean, url: string, options?: InAppBrowserOptions, alertMessage?: string): - Promise { + async openWithAutoLogin( + inApp: boolean, + url: string, + options?: InAppBrowserOptions, + alertMessage?: string, + ): Promise { // Get the URL to open. - return this.getAutoLoginUrl(url).then((url) => { - if (!alertMessage) { - // Just open the URL. - if (inApp) { - return CoreUtils.instance.openInApp(url, options); - } else { - return CoreUtils.instance.openInBrowser(url); - } - } + url = await this.getAutoLoginUrl(url); + if (alertMessage) { // Show an alert first. - return CoreDomUtils.instance.showAlert(Translate.instance.instant('core.notice'), alertMessage, undefined, 3000) - .then(() => new Promise(() => { - // @todo - // const subscription = alert.didDismiss.subscribe(() => { - // subscription && subscription.unsubscribe(); + const alert = await CoreDomUtils.instance.showAlert( + Translate.instance.instant('core.notice'), + alertMessage, + undefined, + 3000, + ); - // if (inApp) { - // resolve(CoreUtils.instance.openInApp(url, options)); - // } else { - // resolve(CoreUtils.instance.openInBrowser(url)); - // } - // }); - })); - }); + await alert.onDidDismiss(); + } + + // Open the URL. + if (inApp) { + return CoreUtils.instance.openInApp(url, options); + } else { + return CoreUtils.instance.openInBrowser(url); + } } /** @@ -1501,8 +1549,12 @@ export class CoreSite { * @param alertMessage If defined, an alert will be shown before opening the browser/inappbrowser. * @return Promise resolved when done. Resolve param is returned only if inApp=true. */ - openWithAutoLoginIfSameSite(inApp: boolean, url: string, options?: InAppBrowserOptions, alertMessage?: string): - Promise { + async openWithAutoLoginIfSameSite( + inApp: boolean, + url: string, + options?: InAppBrowserOptions, + alertMessage?: string, + ): Promise { if (this.containsUrl(url)) { return this.openWithAutoLogin(inApp, url, options, alertMessage); } else { @@ -1522,6 +1574,8 @@ export class CoreSite { * @param ignoreCache True if it should ignore cached data. * @return Promise resolved with site config. */ + getConfig(name?: undefined, ignoreCache?: boolean): Promise; + getConfig(name: string, ignoreCache?: boolean): Promise; getConfig(name?: string, ignoreCache?: boolean): Promise { const preSets: CoreSiteWSPreSets = { cacheKey: this.getConfigCacheKey(), @@ -1578,7 +1632,9 @@ export class CoreSite { * @param name Name of the setting to get. If not set, all settings will be returned. * @return Site config or a specific setting. */ - getStoredConfig(name?: string): string | CoreSiteConfig { + getStoredConfig(): CoreSiteConfig | undefined; + getStoredConfig(name: string): string | undefined; + getStoredConfig(name?: string): string | CoreSiteConfig | undefined { if (!this.config) { return; } @@ -1597,7 +1653,7 @@ export class CoreSite { * @return Whether it's disabled. */ isFeatureDisabled(name: string): boolean { - const disabledFeatures = this.getStoredConfig('tool_mobile_disabledfeatures'); + const disabledFeatures = this.getStoredConfig('tool_mobile_disabledfeatures'); if (!disabledFeatures) { return false; } @@ -1645,7 +1701,13 @@ export class CoreSite { * it's the last released major version. */ isVersionGreaterEqualThan(versions: string | string[]): boolean { - const siteVersion = parseInt(this.getInfo().version, 10); + const info = this.getInfo(); + + if (!info || !info.version) { + return false; + } + + const siteVersion = Number(info.version); if (Array.isArray(versions)) { if (!versions.length) { @@ -1679,26 +1741,27 @@ export class CoreSite { * @param showModal Whether to show a loading modal. * @return Promise resolved with the converted URL. */ - getAutoLoginUrl(url: string, showModal: boolean = true): Promise { + async getAutoLoginUrl(url: string, showModal: boolean = true): Promise { if (!this.privateToken || !this.wsAvailable('tool_mobile_get_autologin_key') || (this.lastAutoLogin && CoreTimeUtils.instance.timestamp() - this.lastAutoLogin < CoreConstants.SECONDS_MINUTE * 6)) { // No private token, WS not available or last auto-login was less than 6 minutes ago. Don't change the URL. - - return Promise.resolve(url); + return url; } const userId = this.getUserId(); const params = { privatetoken: this.privateToken, }; - let modal; + let modal: CoreIonLoadingElement | undefined; if (showModal) { - modal = CoreDomUtils.instance.showModalLoading(); + modal = await CoreDomUtils.instance.showModalLoading(); } - // Use write to not use cache. - return this.write('tool_mobile_get_autologin_key', params).then((data) => { + try { + // Use write to not use cache. + const data = await this.write('tool_mobile_get_autologin_key', params); + if (!data.autologinurl || !data.key) { // Not valid data, return the same URL. return url; @@ -1707,13 +1770,12 @@ export class CoreSite { this.lastAutoLogin = CoreTimeUtils.instance.timestamp(); return data.autologinurl + '?userid=' + userId + '&key=' + data.key + '&urltogo=' + encodeURIComponent(url); - }).catch(() => - + } catch (error) { // Couldn't get autologin key, return the same URL. - url, - ).finally(() => { - modal && modal.dismiss(); - }); + return url; + } finally { + modal?.dismiss(); + } } /** @@ -1733,7 +1795,7 @@ export class CoreSite { if (typeof this.MOODLE_RELEASES[data.major] == 'undefined') { // Major version not found. Use the last one. - data.major = Object.keys(this.MOODLE_RELEASES).pop(); + data.major = Object.keys(this.MOODLE_RELEASES).pop()!; } return this.MOODLE_RELEASES[data.major] + data.minor; @@ -1790,7 +1852,7 @@ export class CoreSite { * @return Promise resolved when done. */ async deleteSiteConfig(name: string): Promise { - await this.db.deleteRecords(CoreSite.CONFIG_TABLE, { name }); + await this.getDb().deleteRecords(CoreSite.CONFIG_TABLE, { name }); } /** @@ -1800,15 +1862,18 @@ export class CoreSite { * @param defaultValue Default value to use if the entry is not found. * @return Resolves upon success along with the config data. Reject on failure. */ - getLocalSiteConfig(name: string, defaultValue?: T): Promise { - return this.db.getRecord(CoreSite.CONFIG_TABLE, { name }).then((entry) => entry.value) - .catch((error) => { + async getLocalSiteConfig(name: string, defaultValue?: T): Promise { + try { + const entry = await this.getDb().getRecord(CoreSite.CONFIG_TABLE, { name }); + + return entry.value; + } catch (error) { if (typeof defaultValue != 'undefined') { return defaultValue; } - return Promise.reject(error); - }); + throw error; + } } /** @@ -1819,7 +1884,7 @@ export class CoreSite { * @return Promise resolved when done. */ async setLocalSiteConfig(name: string, value: number | string): Promise { - await this.db.insertRecord(CoreSite.CONFIG_TABLE, { name, value }); + await this.getDb().insertRecord(CoreSite.CONFIG_TABLE, { name, value }); } /** @@ -1829,6 +1894,7 @@ export class CoreSite { * @return Expiration delay. */ getExpirationDelay(updateFrequency?: number): number { + updateFrequency = updateFrequency || CoreSite.FREQUENCY_USUALLY; let expirationDelay = this.UPDATE_FREQUENCIES[updateFrequency] || this.UPDATE_FREQUENCIES[CoreSite.FREQUENCY_USUALLY]; if (CoreApp.instance.isNetworkAccessLimited()) { @@ -2010,15 +2076,14 @@ export type LocalMobileResponse = { /** * Info of a request waiting in the queue. */ -type RequestQueueItem = { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RequestQueueItem = { cacheId: string; method: string; - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any - data: any; + data: any; // eslint-disable-line @typescript-eslint/no-explicit-any preSets: CoreSiteWSPreSets; wsPreSets: CoreWSPreSets; - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any - deferred: PromiseDefer; + deferred: PromiseDefer; }; /** @@ -2166,3 +2231,12 @@ export type CoreSiteConfigDBRecord = { name: string; value: string | number; }; + +export type CoreSiteWSCacheRecord = { + id: string; + data: string; + expirationTime: number; + key?: string; + component?: string; + componentId?: number; +}; diff --git a/src/app/classes/sqlitedb.ts b/src/app/classes/sqlitedb.ts index a46729325..14fff4066 100644 --- a/src/app/classes/sqlitedb.ts +++ b/src/app/classes/sqlitedb.ts @@ -472,7 +472,7 @@ export class SQLiteDB { * @return List of params. */ protected formatDataToSQLParams(data: SQLiteDBRecordValues): SQLiteDBRecordValue[] { - return Object.keys(data).map((key) => data[key]); + return Object.keys(data).map((key) => data[key]!); } /** @@ -1087,7 +1087,7 @@ export class SQLiteDB { } export type SQLiteDBRecordValues = { - [key in string ]: SQLiteDBRecordValue; + [key in string ]: SQLiteDBRecordValue | undefined; }; export type SQLiteDBQueryParams = { diff --git a/src/app/components/icon/icon.ts b/src/app/components/icon/icon.ts index eea96b488..830ed8375 100644 --- a/src/app/components/icon/icon.ts +++ b/src/app/components/icon/icon.ts @@ -38,7 +38,7 @@ export class CoreIconComponent implements OnChanges, OnDestroy { @Input() ios?: string; // FontAwesome params. - @Input('fixed-width') fixedWidth: boolean; + @Input('fixed-width') fixedWidth?: boolean; // eslint-disable-line @angular-eslint/no-input-rename @Input() label?: string; @Input() flipRtl?: boolean; // Whether to flip the icon in RTL. Defaults to false. @@ -48,7 +48,7 @@ export class CoreIconComponent implements OnChanges, OnDestroy { constructor(el: ElementRef) { this.element = el.nativeElement; - this.newElement = this.element + this.newElement = this.element; } /** diff --git a/src/app/components/loading/loading.ts b/src/app/components/loading/loading.ts index baf58d6cf..6ce15ee24 100644 --- a/src/app/components/loading/loading.ts +++ b/src/app/components/loading/loading.ts @@ -48,9 +48,9 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit { @Input() hideUntil: unknown; // Determine when should the contents be shown. @Input() message?: string; // Message to show while loading. - @ViewChild('content') content: ElementRef; + @ViewChild('content') content?: ElementRef; - protected uniqueId: string; + protected uniqueId!: string; protected element: HTMLElement; // Current element. constructor(element: ElementRef) { diff --git a/src/app/components/show-password/show-password.ts b/src/app/components/show-password/show-password.ts index e2d79fa4d..12dafefe4 100644 --- a/src/app/components/show-password/show-password.ts +++ b/src/app/components/show-password/show-password.ts @@ -40,16 +40,16 @@ import { CoreUtils } from '@services/utils/utils'; }) export class CoreShowPasswordComponent implements OnInit, AfterViewInit { - @Input() name: string; // Name of the input affected. + @Input() name?: string; // Name of the input affected. @Input() initialShown?: boolean | string; // Whether the password should be shown at start. - @ContentChild(IonInput) ionInput: IonInput; + @ContentChild(IonInput) ionInput?: IonInput; - shown: boolean; // Whether the password is shown. - label: string; // Label for the button to show/hide. - iconName: string; // Name of the icon of the button to show/hide. + shown!: boolean; // Whether the password is shown. + label?: string; // Label for the button to show/hide. + iconName?: string; // Name of the icon of the button to show/hide. selector = ''; // Selector to identify the input. - protected input: HTMLInputElement; // Input affected. + protected input?: HTMLInputElement | null; // Input affected. protected element: HTMLElement; // Current element. constructor(element: ElementRef) { @@ -84,7 +84,7 @@ export class CoreShowPasswordComponent implements OnInit, AfterViewInit { } // Search the input. - this.input = this.element.querySelector(this.selector); + this.input = this.element.querySelector(this.selector); if (this.input) { // Input found. Set the right type. @@ -128,7 +128,7 @@ export class CoreShowPasswordComponent implements OnInit, AfterViewInit { if (isFocused && CoreApp.instance.isAndroid()) { // In Android, the keyboard is closed when the input type changes. Focus it again. setTimeout(() => { - CoreDomUtils.instance.focusElement(this.input); + CoreDomUtils.instance.focusElement(this.input!); }, 400); } } diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index def5471dd..41c7c1b3f 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -39,7 +39,7 @@ export class CoreConstants { static readonly DOWNLOAD_THRESHOLD = 10485760; // 10MB. static readonly MINIMUM_FREE_SPACE = 10485760; // 10MB. static readonly IOS_FREE_SPACE_THRESHOLD = 524288000; // 500MB. - static readonly DONT_SHOW_ERROR = 'CoreDontShowError'; + static readonly DONT_SHOW_ERROR = 'CoreDontShowError'; // @deprecated since 3.9.5. Use CoreSilentError instead. static readonly NO_SITE_ID = 'NoSite'; // Settings constants. diff --git a/src/app/core/emulator/classes/sqlitedb.ts b/src/app/core/emulator/classes/sqlitedb.ts index b327029fd..055944f71 100644 --- a/src/app/core/emulator/classes/sqlitedb.ts +++ b/src/app/core/emulator/classes/sqlitedb.ts @@ -35,6 +35,7 @@ export class SQLiteDBMock extends SQLiteDB { * * @return Promise resolved when done. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any close(): Promise { // WebSQL databases aren't closed. return Promise.resolve(); @@ -45,6 +46,7 @@ export class SQLiteDBMock extends SQLiteDB { * * @return Promise resolved when done. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any async emptyDatabase(): Promise { await this.ready(); @@ -89,6 +91,7 @@ export class SQLiteDBMock extends SQLiteDB { * @param params Query parameters. * @return Promise resolved with the result. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any async execute(sql: string, params?: any[]): Promise { await this.ready(); @@ -115,6 +118,7 @@ export class SQLiteDBMock extends SQLiteDB { * @param sqlStatements SQL statements to execute. * @return Promise resolved with the result. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any async executeBatch(sqlStatements: any[]): Promise { await this.ready(); @@ -148,6 +152,7 @@ export class SQLiteDBMock extends SQLiteDB { })); }); + // eslint-disable-next-line promise/catch-or-return Promise.all(promises).then(resolve, reject); }); }); @@ -158,6 +163,7 @@ export class SQLiteDBMock extends SQLiteDB { */ init(): void { // This DB is for desktop apps, so use a big size to be sure it isn't filled. + // eslint-disable-next-line @typescript-eslint/no-explicit-any this.db = ( window).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024); this.promise = Promise.resolve(); } diff --git a/src/app/core/login/pages/credentials/credentials.page.ts b/src/app/core/login/pages/credentials/credentials.page.ts index 57fa2913e..3bcc2f00e 100644 --- a/src/app/core/login/pages/credentials/credentials.page.ts +++ b/src/app/core/login/pages/credentials/credentials.page.ts @@ -36,27 +36,27 @@ import { CoreEvents, CoreEventsProvider } from '@/app/services/events'; }) export class CoreLoginCredentialsPage implements OnInit, OnDestroy { - @ViewChild('credentialsForm') formElement: ElementRef; + @ViewChild('credentialsForm') formElement?: ElementRef; - credForm: FormGroup; - siteUrl: string; + credForm!: FormGroup; + siteUrl!: string; siteChecked = false; - siteName: string; - logoUrl: string; - authInstructions: string; - canSignup: boolean; - identityProviders: CoreSiteIdentityProvider[]; + siteName?: string; + logoUrl?: string; + authInstructions?: string; + canSignup?: boolean; + identityProviders?: CoreSiteIdentityProvider[]; pageLoaded = false; isBrowserSSO = false; isFixedUrlSet = false; showForgottenPassword = true; showScanQR: boolean; - protected siteConfig: CoreSitePublicConfigResponse; + protected siteConfig?: CoreSitePublicConfigResponse; protected eventThrown = false; protected viewLeft = false; - protected siteId: string; - protected urlToOpen: string; + protected siteId?: string; + protected urlToOpen?: string; constructor( protected fb: FormBuilder, @@ -82,8 +82,8 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { ngOnInit(): void { this.route.queryParams.subscribe(params => { this.siteUrl = params['siteUrl']; - this.siteName = params['siteName'] || null; - this.logoUrl = !CoreConfigConstants.forceLoginLogo && params['logoUrl'] || null; + this.siteName = params['siteName'] || undefined; + this.logoUrl = !CoreConfigConstants.forceLoginLogo && params['logoUrl'] || undefined; this.siteConfig = params['siteConfig']; this.urlToOpen = params['urlToOpen']; @@ -138,7 +138,11 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { // Check that there's no SSO authentication ongoing and the view hasn't changed. if (!CoreApp.instance.isSSOAuthenticationOngoing() && !this.viewLeft) { CoreLoginHelper.instance.confirmAndOpenBrowserForSSOLogin( - result.siteUrl, result.code, result.service, result.config?.launchurl); + result.siteUrl, + result.code, + result.service, + result.config?.launchurl, + ); } } else { this.isBrowserSSO = false; @@ -171,7 +175,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { CoreEvents.instance.trigger(CoreEventsProvider.LOGIN_SITE_CHECKED, { config: this.siteConfig }); } } else { - this.authInstructions = null; + this.authInstructions = undefined; this.canSignup = false; this.identityProviders = []; } @@ -261,7 +265,11 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { */ forgottenPassword(): void { CoreLoginHelper.instance.forgottenPasswordClicked( - this.navCtrl, this.siteUrl, this.credForm.value.username, this.siteConfig); + this.navCtrl, + this.siteUrl, + this.credForm.value.username, + this.siteConfig, + ); } /** @@ -270,7 +278,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { * @param provider The provider that was clicked. */ oauthClicked(provider: CoreSiteIdentityProvider): void { - if (!CoreLoginHelper.instance.openBrowserForOAuthLogin(this.siteUrl, provider, this.siteConfig.launchurl)) { + if (!CoreLoginHelper.instance.openBrowserForOAuthLogin(this.siteUrl, provider, this.siteConfig?.launchurl)) { CoreDomUtils.instance.showErrorModal('Invalid data.'); } } @@ -289,8 +297,10 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { // Show some instructions first. CoreDomUtils.instance.showAlertWithOptions({ header: Translate.instance.instant('core.login.faqwhereisqrcode'), - message: Translate.instance.instant('core.login.faqwhereisqrcodeanswer', - { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }), + message: Translate.instance.instant( + 'core.login.faqwhereisqrcodeanswer', + { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }, + ), buttons: [ { text: Translate.instance.instant('core.cancel'), diff --git a/src/app/core/login/pages/init/init.page.ts b/src/app/core/login/pages/init/init.page.ts index 006ec48c6..762be4743 100644 --- a/src/app/core/login/pages/init/init.page.ts +++ b/src/app/core/login/pages/init/init.page.ts @@ -34,44 +34,44 @@ export class CoreLoginInitPage implements OnInit { /** * Initialize the component. */ - ngOnInit(): void { + async ngOnInit(): Promise { // Wait for the app to be ready. - CoreInit.instance.ready().then(() => { - // Check if there was a pending redirect. - const redirectData = CoreApp.instance.getRedirect(); - if (redirectData.siteId) { - // Unset redirect data. - CoreApp.instance.storeRedirect('', '', {}); + await CoreInit.instance.ready(); - // Only accept the redirect if it was stored less than 20 seconds ago. - if (Date.now() - redirectData.timemodified < 20000) { - // if (redirectData.siteId != CoreConstants.NO_SITE_ID) { - // // The redirect is pointing to a site, load it. - // return this.sitesProvider.loadSite(redirectData.siteId, redirectData.page, redirectData.params) - // .then((loggedIn) => { + // Check if there was a pending redirect. + const redirectData = CoreApp.instance.getRedirect(); + if (redirectData.siteId) { + // Unset redirect data. + CoreApp.instance.storeRedirect('', '', {}); - // if (loggedIn) { - // return this.loginHelper.goToSiteInitialPage(this.navCtrl, redirectData.page, redirectData.params, - // { animate: false }); - // } - // }).catch(() => { - // // Site doesn't exist. - // return this.loadPage(); - // }); - // } else { - // // No site to load, open the page. - // return this.loginHelper.goToNoSitePage(this.navCtrl, redirectData.page, redirectData.params); - // } - } + // Only accept the redirect if it was stored less than 20 seconds ago. + if (redirectData.timemodified && Date.now() - redirectData.timemodified < 20000) { + // if (redirectData.siteId != CoreConstants.NO_SITE_ID) { + // // The redirect is pointing to a site, load it. + // return this.sitesProvider.loadSite(redirectData.siteId, redirectData.page, redirectData.params) + // .then((loggedIn) => { + + // if (loggedIn) { + // return this.loginHelper.goToSiteInitialPage(this.navCtrl, redirectData.page, redirectData.params, + // { animate: false }); + // } + // }).catch(() => { + // // Site doesn't exist. + // return this.loadPage(); + // }); + // } else { + // // No site to load, open the page. + // return this.loginHelper.goToNoSitePage(this.navCtrl, redirectData.page, redirectData.params); + // } } + } - return this.loadPage(); - }).then(() => { - // If we hide the splash screen now, the init view is still seen for an instant. Wait a bit to make sure it isn't seen. - setTimeout(() => { - SplashScreen.instance.hide(); - }, 100); - }); + await this.loadPage(); + + // If we hide the splash screen now, the init view is still seen for an instant. Wait a bit to make sure it isn't seen. + setTimeout(() => { + SplashScreen.instance.hide(); + }, 100); } /** diff --git a/src/app/core/login/pages/site/site.page.ts b/src/app/core/login/pages/site/site.page.ts index 465181352..432c52a12 100644 --- a/src/app/core/login/pages/site/site.page.ts +++ b/src/app/core/login/pages/site/site.page.ts @@ -40,11 +40,11 @@ import { NavController } from '@ionic/angular'; }) export class CoreLoginSitePage implements OnInit { - @ViewChild('siteFormEl') formElement: ElementRef; + @ViewChild('siteFormEl') formElement?: ElementRef; siteForm: FormGroup; - fixedSites: CoreLoginSiteInfoExtended[]; - filteredSites: CoreLoginSiteInfoExtended[]; + fixedSites?: CoreLoginSiteInfoExtended[]; + filteredSites?: CoreLoginSiteInfoExtended[]; siteSelector = 'sitefinder'; showKeyboard = false; filter = ''; @@ -53,7 +53,7 @@ export class CoreLoginSitePage implements OnInit { loadingSites = false; searchFunction: (search: string) => void; showScanQR: boolean; - enteredSiteUrl: CoreLoginSiteInfoExtended; + enteredSiteUrl?: CoreLoginSiteInfoExtended; siteFinderSettings: SiteFinderSettings; constructor( @@ -95,10 +95,10 @@ export class CoreLoginSitePage implements OnInit { if (search.length >= 3) { // Update the sites list. - this.sites = await CoreSites.instance.findSites(search); + const sites = await CoreSites.instance.findSites(search); // Add UI tweaks. - this.sites = this.extendCoreLoginSiteInfo(this.sites); + this.sites = this.extendCoreLoginSiteInfo( sites); this.hasSites = !!this.sites.length; } else { @@ -201,7 +201,7 @@ export class CoreLoginSitePage implements OnInit { let valid = value.length >= 3 && CoreUrl.isValidMoodleUrl(value); if (!valid) { - const demo = !!this.getDemoSiteData(value); + const demo = !!CoreSites.instance.getDemoSiteData(value); if (demo) { valid = true; @@ -212,19 +212,6 @@ export class CoreLoginSitePage implements OnInit { }; } - /** - * Get the demo data for a certain "name" if it is a demo site. - * - * @param name Name of the site to check. - * @return Site data if it's a demo site, undefined otherwise. - */ - getDemoSiteData(name: string): CoreSitesDemoSiteData { - const demoSites = CoreConfigConstants.demo_sites; - if (typeof demoSites != 'undefined' && typeof demoSites[name] != 'undefined') { - return demoSites[name]; - } - } - /** * Show a help modal. */ @@ -361,7 +348,11 @@ export class CoreLoginSitePage implements OnInit { if (CoreLoginHelper.instance.isSSOLoginNeeded(response.code)) { // SSO. User needs to authenticate in a browser. CoreLoginHelper.instance.confirmAndOpenBrowserForSSOLogin( - response.siteUrl, response.code, response.service, response.config && response.config.launchurl); + response.siteUrl, + response.code, + response.service, + response.config?.launchurl, + ); } else { const pageParams = { siteUrl: response.siteUrl, siteConfig: response.config }; if (foundSite) { @@ -382,7 +373,7 @@ export class CoreLoginSitePage implements OnInit { * @param url The URL the user was trying to connect to. * @param error Error to display. */ - protected showLoginIssue(url: string, error: CoreError): void { + protected showLoginIssue(url: string | null, error: CoreError): void { let errorMessage = CoreDomUtils.instance.getErrorMessage(error); if (errorMessage == Translate.instance.instant('core.cannotconnecttrouble')) { @@ -452,10 +443,12 @@ export class CoreLoginSitePage implements OnInit { this.enteredSiteUrl = { url: search, name: 'connect', + title: '', + location: '', noProtocolUrl: CoreUrl.removeProtocol(search), }; } else { - this.enteredSiteUrl = null; + this.enteredSiteUrl = undefined; } this.searchFunction(search.trim()); @@ -468,8 +461,10 @@ export class CoreLoginSitePage implements OnInit { // Show some instructions first. CoreDomUtils.instance.showAlertWithOptions({ header: Translate.instance.instant('core.login.faqwhereisqrcode'), - message: Translate.instance.instant('core.login.faqwhereisqrcodeanswer', - { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }), + message: Translate.instance.instant( + 'core.login.faqwhereisqrcodeanswer', + { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }, + ), buttons: [ { text: Translate.instance.instant('core.cancel'), @@ -505,9 +500,9 @@ export class CoreLoginSitePage implements OnInit { * Extended data for UI implementation. */ type CoreLoginSiteInfoExtended = CoreLoginSiteInfo & { - noProtocolUrl?: string; // Url wihtout protocol. - location?: string; // City + country. - title?: string; // Name + alias. + noProtocolUrl: string; // Url wihtout protocol. + location: string; // City + country. + title: string; // Name + alias. }; type SiteFinderSettings = { diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts index a4256866e..cd3f3dbdf 100644 --- a/src/app/core/login/services/helper.ts +++ b/src/app/core/login/services/helper.ts @@ -50,7 +50,7 @@ export class CoreLoginHelperProvider { protected logger: CoreLogger; protected isSSOConfirmShown = false; protected isOpenEditAlertShown = false; - protected pageToLoad: {page: string; params: Params; time: number}; // Page to load once main menu is opened. + protected pageToLoad?: {page: string; params: Params; time: number}; // Page to load once main menu is opened. protected isOpeningReconnect = false; waitingForBrowser = false; @@ -208,7 +208,7 @@ export class CoreLoginHelperProvider { const categories: Record = {}; profileFields.forEach((field) => { - if (!field.signup) { + if (!field.signup || !field.categoryid) { // Not a signup field, ignore it. return; } @@ -216,7 +216,7 @@ export class CoreLoginHelperProvider { if (!categories[field.categoryid]) { categories[field.categoryid] = { id: field.categoryid, - name: field.categoryname, + name: field.categoryname || '', fields: [], }; } @@ -233,8 +233,8 @@ export class CoreLoginHelperProvider { * @param config Site public config. * @return Disabled features. */ - getDisabledFeatures(config: CoreSitePublicConfigResponse): string { - const disabledFeatures = config && config.tool_mobile_disabledfeatures; + getDisabledFeatures(config?: CoreSitePublicConfigResponse): string { + const disabledFeatures = config?.tool_mobile_disabledfeatures; if (!disabledFeatures) { return ''; } @@ -302,8 +302,8 @@ export class CoreLoginHelperProvider { * @param config Site public config. * @return Logo URL. */ - getLogoUrl(config: CoreSitePublicConfigResponse): string { - return !CoreConfigConstants.forceLoginLogo && config ? (config.logourl || config.compactlogourl) : null; + getLogoUrl(config: CoreSitePublicConfigResponse): string | undefined { + return !CoreConfigConstants.forceLoginLogo && config ? (config.logourl || config.compactlogourl) : undefined; } /** @@ -314,7 +314,7 @@ export class CoreLoginHelperProvider { */ getLogoutLabel(site?: CoreSite): string { site = site || CoreSites.instance.getCurrentSite(); - const config = site.getStoredConfig(); + const config = site?.getStoredConfig(); return 'core.mainmenu.' + (config && config.tool_mobile_forcelogout == '1' ? 'logout' : 'changesite'); } @@ -325,7 +325,7 @@ export class CoreLoginHelperProvider { * @param params Params. * @return OAuth ID. */ - getOAuthIdFromParams(params: CoreUrlParams): number { + getOAuthIdFromParams(params: CoreUrlParams): number | undefined { return params && typeof params.oauthsso != 'undefined' ? Number(params.oauthsso) : undefined; } @@ -338,15 +338,18 @@ export class CoreLoginHelperProvider { async getSitePolicy(siteId?: string): Promise { const site = await CoreSites.instance.getSite(siteId); - let sitePolicy: string; + let sitePolicy: string | undefined; try { // Try to get the latest config, maybe the site policy was just added or has changed. sitePolicy = await site.getConfig('sitepolicy', true); } catch (error) { // Cannot get config, try to get the site policy using auth_email_get_signup_settings. - const settings = await CoreWS.instance.callAjax('auth_email_get_signup_settings', {}, - { siteUrl: site.getURL() }); + const settings = await CoreWS.instance.callAjax( + 'auth_email_get_signup_settings', + {}, + { siteUrl: site.getURL() }, + ); sitePolicy = settings.sitepolicy; } @@ -374,7 +377,10 @@ export class CoreLoginHelperProvider { * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. * @return Valid identity providers. */ - getValidIdentityProviders(siteConfig: CoreSitePublicConfigResponse, disabledFeatures?: string): CoreSiteIdentityProvider[] { + getValidIdentityProviders(siteConfig?: CoreSitePublicConfigResponse, disabledFeatures?: string): CoreSiteIdentityProvider[] { + if (!siteConfig) { + return []; + } if (this.isFeatureDisabled('NoDelegate_IdentityProviders', siteConfig, disabledFeatures)) { // Identity providers are disabled, return an empty list. return []; @@ -460,8 +466,8 @@ export class CoreLoginHelperProvider { * @return Whether there are several fixed URLs. */ hasSeveralFixedSites(): boolean { - return CoreConfigConstants.siteurl && Array.isArray(CoreConfigConstants.siteurl) && - CoreConfigConstants.siteurl.length > 1; + return !!(CoreConfigConstants.siteurl && Array.isArray(CoreConfigConstants.siteurl) && + CoreConfigConstants.siteurl.length > 1); } /** @@ -923,8 +929,14 @@ export class CoreLoginHelperProvider { try { this.waitingForBrowser = true; - this.openBrowserForSSOLogin(result.siteUrl, result.code, result.service, result.config?.launchurl, - data.pageName, data.params); + this.openBrowserForSSOLogin( + result.siteUrl, + result.code, + result.service, + result.config?.launchurl, + data.pageName, + data.params, + ); } catch (error) { // User cancelled, logout him. CoreSites.instance.logout(); @@ -956,8 +968,13 @@ export class CoreLoginHelperProvider { this.waitingForBrowser = true; CoreSites.instance.unsetCurrentSite(); // Unset current site to make authentication work fine. - this.openBrowserForOAuthLogin(siteUrl, providerToUse, result.config.launchurl, data.pageName, - data.params); + this.openBrowserForOAuthLogin( + siteUrl, + providerToUse, + result.config?.launchurl, + data.pageName, + data.params, + ); } catch (error) { // User cancelled, logout him. CoreSites.instance.logout(); @@ -1067,10 +1084,13 @@ export class CoreLoginHelperProvider { try { const result = await CoreWS.instance.callAjax( - 'core_auth_resend_confirmation_email', data, preSets); + 'core_auth_resend_confirmation_email', + data, + preSets, + ); if (!result.status) { - throw new CoreWSError(result.warnings[0]); + throw new CoreWSError(result.warnings?.[0]); } const message = Translate.instance.instant('core.login.emailconfirmsentsuccess'); @@ -1096,6 +1116,8 @@ export class CoreLoginHelperProvider { try { // This call will always fail because we aren't sending parameters. await CoreWS.instance.callAjax('core_auth_resend_confirmation_email', {}, { siteUrl }); + + return true; // We should never reach here. } catch (error) { // If the WS responds with an invalid parameter error it means the WS is avaiable. return error?.errorcode === 'invalidparameter'; @@ -1134,13 +1156,13 @@ export class CoreLoginHelperProvider { */ treatUserTokenError(siteUrl: string, error: CoreWSError, username?: string, password?: string): void { if (error.errorcode == 'forcepasswordchangenotice') { - this.openChangePassword(siteUrl, CoreTextUtils.instance.getErrorMessageFromError(error)); + this.openChangePassword(siteUrl, CoreTextUtils.instance.getErrorMessageFromError(error)!); } else if (error.errorcode == 'usernotconfirmed') { this.showNotConfirmedModal(siteUrl, undefined, username, password); } else if (error.errorcode == 'connecttomoodleapp') { - this.showMoodleAppNoticeModal(CoreTextUtils.instance.getErrorMessageFromError(error)); + this.showMoodleAppNoticeModal(CoreTextUtils.instance.getErrorMessageFromError(error)!); } else if (error.errorcode == 'connecttoworkplaceapp') { - this.showWorkplaceNoticeModal(CoreTextUtils.instance.getErrorMessageFromError(error)); + this.showWorkplaceNoticeModal(CoreTextUtils.instance.getErrorMessageFromError(error)!); } else { CoreDomUtils.instance.showErrorModal(error); } diff --git a/src/app/directives/format-text.ts b/src/app/directives/format-text.ts index e4c74ebaa..7c679563e 100644 --- a/src/app/directives/format-text.ts +++ b/src/app/directives/format-text.ts @@ -15,7 +15,7 @@ import { Directive, ElementRef, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional } from '@angular/core'; import { NavController, IonContent } from '@ionic/angular'; -import { CoreEventLoadingChangedData, CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents, CoreEventsProvider } from '@services/events'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreIframeUtils, CoreIframeUtilsProvider } from '@services/utils/iframe'; @@ -38,7 +38,7 @@ import { Translate } from '@singletons/core.singletons'; }) export class CoreFormatTextDirective implements OnChanges { - @Input() text: string; // The text to format. + @Input() text?: string; // The text to format. @Input() siteId?: string; // Site ID to use. @Input() component?: string; // Component for CoreExternalContentDirective. @Input() componentId?: string | number; // Component ID to use in conjunction with the component. @@ -56,11 +56,11 @@ export class CoreFormatTextDirective implements OnChanges { @Input() contextInstanceId?: number; // The instance ID related to the context. @Input() courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters. @Input() wsNotFiltered?: boolean | string; // If true it means the WS didn't filter the text for some reason. - @Output() afterRender?: EventEmitter; // Called when the data is rendered. + @Output() afterRender: EventEmitter; // Called when the data is rendered. protected element: HTMLElement; - protected showMoreDisplayed: boolean; - protected loadingChangedListener; + protected showMoreDisplayed = false; + protected loadingChangedListener?: CoreEventObserver; constructor( element: ElementRef, @@ -116,9 +116,9 @@ export class CoreFormatTextDirective implements OnChanges { const container = document.createElement('span'); const originalWidth = img.attributes.getNamedItem('width'); - const forcedWidth = parseInt(originalWidth && originalWidth.value); + const forcedWidth = Number(originalWidth?.value); if (!isNaN(forcedWidth)) { - if (originalWidth.value.indexOf('%') < 0) { + if (originalWidth!.value.indexOf('%') < 0) { img.style.width = forcedWidth + 'px'; } else { img.style.width = forcedWidth + '%'; @@ -160,7 +160,7 @@ export class CoreFormatTextDirective implements OnChanges { return; } - let imgWidth = parseInt(img.getAttribute('width')); + let imgWidth = Number(img.getAttribute('width')); if (!imgWidth) { // No width attribute, use real size. imgWidth = img.naturalWidth; @@ -185,7 +185,7 @@ export class CoreFormatTextDirective implements OnChanges { CoreDomUtils.instance.viewImage(imgSrc, img.getAttribute('alt'), this.component, this.componentId, true); }); - img.parentNode.appendChild(anchor); + img.parentNode?.appendChild(anchor); }); } @@ -194,10 +194,13 @@ export class CoreFormatTextDirective implements OnChanges { */ protected calculateHeight(): void { // @todo: Work on calculate this height better. + if (!this.maxHeight) { + return; + } // Remove max-height (if any) to calculate the real height. const initialMaxHeight = this.element.style.maxHeight; - this.element.style.maxHeight = null; + this.element.style.maxHeight = ''; const height = this.getElementHeight(this.element); @@ -245,6 +248,9 @@ export class CoreFormatTextDirective implements OnChanges { // Ignore it if the event was prevented by some other listener. return; } + if (!this.text) { + return; + } const expandInFullview = CoreUtils.instance.isTrueOrOne(this.fullOnClick) || false; @@ -265,14 +271,18 @@ export class CoreFormatTextDirective implements OnChanges { // Open a new state with the contents. const filter = typeof this.filter != 'undefined' ? CoreUtils.instance.isTrueOrOne(this.filter) : undefined; - CoreTextUtils.instance.viewText(this.fullTitle || Translate.instance.instant('core.description'), this.text, { - component: this.component, - componentId: this.componentId, - filter: filter, - contextLevel: this.contextLevel, - instanceId: this.contextInstanceId, - courseId: this.courseId, - }); + CoreTextUtils.instance.viewText( + this.fullTitle || Translate.instance.instant('core.description'), + this.text, + { + component: this.component, + componentId: this.componentId, + filter: filter, + contextLevel: this.contextLevel, + instanceId: this.contextInstanceId, + courseId: this.courseId, + }, + ); } } @@ -357,29 +367,17 @@ export class CoreFormatTextDirective implements OnChanges { * @return Promise resolved with a div element containing the code. */ protected async formatContents(): Promise { - - const result: FormatContentsResult = { - div: null, - filters: [], - options: {}, - siteId: this.siteId, - }; - // Retrieve the site since it might be needed later. const site = await CoreUtils.instance.ignoreErrors(CoreSites.instance.getSite(this.siteId)); - if (site) { - result.siteId = site.getId(); - } - - if (this.contextLevel == 'course' && this.contextInstanceId <= 0) { + if (site && this.contextLevel == 'course' && this.contextInstanceId !== undefined && this.contextInstanceId <= 0) { this.contextInstanceId = site.getSiteHomeId(); } const filter = typeof this.filter == 'undefined' ? !!(this.contextLevel && typeof this.contextInstanceId != 'undefined') : CoreUtils.instance.isTrueOrOne(this.filter); - result.options = { + const options = { clean: CoreUtils.instance.isTrueOrOne(this.clean), singleLine: CoreUtils.instance.isTrueOrOne(this.singleLine), highlight: this.highlight, @@ -391,10 +389,10 @@ export class CoreFormatTextDirective implements OnChanges { if (filter) { // @todo - formatted = this.text; + formatted = this.text!; } else { // @todo - formatted = this.text; + formatted = this.text!; } formatted = this.treatWindowOpen(formatted); @@ -405,9 +403,12 @@ export class CoreFormatTextDirective implements OnChanges { this.treatHTMLElements(div, site); - result.div = div; - - return result; + return { + div, + filters: [], + options, + siteId: site?.getId(), + }; } /** @@ -418,7 +419,7 @@ export class CoreFormatTextDirective implements OnChanges { * @return Promise resolved when done. */ protected async treatHTMLElements(div: HTMLElement, site?: CoreSite): Promise { - const canTreatVimeo = site && site.isVersionGreaterEqualThan(['3.3.4', '3.4']); + const canTreatVimeo = site?.isVersionGreaterEqualThan(['3.3.4', '3.4']) || false; const navCtrl = this.navCtrl; // @todo this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; const images = Array.from(div.querySelectorAll('img')); @@ -537,7 +538,8 @@ export class CoreFormatTextDirective implements OnChanges { if (!width) { // All elements inside are floating or inline. Change display mode to allow calculate the width. - const parentWidth = CoreDomUtils.instance.getElementWidth(element.parentElement, true, false, false, true); + const parentWidth = element.parentElement ? + CoreDomUtils.instance.getElementWidth(element.parentElement, true, false, false, true) : 0; const previousDisplay = getComputedStyle(element, null).display; element.style.display = 'inline-block'; @@ -578,7 +580,7 @@ export class CoreFormatTextDirective implements OnChanges { this.element.classList.remove('core-expand-in-fullview'); this.element.classList.remove('core-text-formatted'); this.element.classList.remove('core-shortened'); - this.element.style.maxHeight = null; + this.element.style.maxHeight = ''; this.showMoreDisplayed = false; } @@ -595,7 +597,7 @@ export class CoreFormatTextDirective implements OnChanges { const tracks = Array.from(element.querySelectorAll('track')); sources.forEach((source) => { - source.setAttribute('target-src', source.getAttribute('src')); + source.setAttribute('target-src', source.getAttribute('src') || ''); source.removeAttribute('src'); this.addExternalContent(source); }); @@ -618,8 +620,12 @@ export class CoreFormatTextDirective implements OnChanges { * @param canTreatVimeo Whether Vimeo videos can be treated in the site. * @param navCtrl NavController to use. */ - protected async treatIframe(iframe: HTMLIFrameElement, site: CoreSite, canTreatVimeo: boolean, navCtrl: NavController): - Promise { + protected async treatIframe( + iframe: HTMLIFrameElement, + site: CoreSite | undefined, + canTreatVimeo: boolean, + navCtrl: NavController, + ): Promise { const src = iframe.src; const currentSite = CoreSites.instance.getCurrentSite(); @@ -636,7 +642,7 @@ export class CoreFormatTextDirective implements OnChanges { return; } - if (src && canTreatVimeo) { + if (site && src && canTreatVimeo) { // Check if it's a Vimeo video. If it is, use the wsplayer script instead to make restricted videos work. const matches = iframe.src.match(/https?:\/\/player\.vimeo\.com\/video\/([0-9]+)/); if (matches && matches[1]) { @@ -679,7 +685,7 @@ export class CoreFormatTextDirective implements OnChanges { } // Do the iframe responsive. - if (iframe.parentElement.classList.contains('embed-responsive')) { + if (iframe.parentElement?.classList.contains('embed-responsive')) { iframe.addEventListener('load', () => { if (iframe.contentDocument) { const css = document.createElement('style'); @@ -723,5 +729,5 @@ type FormatContentsResult = { div: HTMLElement; filters: any[]; options: any; - siteId: string; + siteId?: string; }; diff --git a/src/app/directives/supress-events.ts b/src/app/directives/supress-events.ts index 0b3c363a2..401578d8b 100644 --- a/src/app/directives/supress-events.ts +++ b/src/app/directives/supress-events.ts @@ -38,7 +38,7 @@ import { Directive, ElementRef, OnInit, Input, Output, EventEmitter } from '@ang }) export class CoreSupressEventsDirective implements OnInit { - @Input('core-suppress-events') suppressEvents: string | string[]; + @Input('core-suppress-events') suppressEvents?: string | string[]; @Output() onClick = new EventEmitter(); // eslint-disable-line @angular-eslint/no-output-on-prefix protected element: HTMLElement; diff --git a/src/app/services/app.ts b/src/app/services/app.ts index 1b3bd700a..5f983adcd 100644 --- a/src/app/services/app.ts +++ b/src/app/services/app.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable, NgZone, ApplicationRef } from '@angular/core'; +import { Params } from '@angular/router'; import { Connection } from '@ionic-native/network/ngx'; import { CoreDB } from '@services/db'; @@ -224,7 +225,7 @@ export class CoreAppProvider { * @param storesConfig Config params to send the user to the right place. * @return Store URL. */ - getAppStoreUrl(storesConfig: CoreStoreConfig): string | null { + getAppStoreUrl(storesConfig: CoreStoreConfig): string | undefined { if (this.isMac() && storesConfig.mac) { return 'itms-apps://itunes.apple.com/app/' + storesConfig.mac; } @@ -253,7 +254,7 @@ export class CoreAppProvider { return storesConfig.mobile; } - return storesConfig.default || null; + return storesConfig.default; } /** @@ -563,11 +564,11 @@ export class CoreAppProvider { * * @return Object with siteid, state, params and timemodified. */ - getRedirect = Record>(): CoreRedirectData { + getRedirect(): CoreRedirectData { if (localStorage?.getItem) { try { const paramsJson = localStorage.getItem('CoreRedirectParams'); - const data: CoreRedirectData = { + const data: CoreRedirectData = { siteId: localStorage.getItem('CoreRedirectSiteId') || undefined, page: localStorage.getItem('CoreRedirectState') || undefined, timemodified: parseInt(localStorage.getItem('CoreRedirectTime') || '0', 10), @@ -593,7 +594,7 @@ export class CoreAppProvider { * @param page Page to go. * @param params Page params. */ - storeRedirect(siteId: string, page: string, params: Record): void { + storeRedirect(siteId: string, page: string, params: Params): void { if (localStorage && localStorage.setItem) { try { localStorage.setItem('CoreRedirectSiteId', siteId); @@ -697,7 +698,7 @@ export class CoreApp extends makeSingleton(CoreAppProvider) {} /** * Data stored for a redirect to another page/site. */ -export type CoreRedirectData> = { +export type CoreRedirectData = { /** * ID of the site to load. */ diff --git a/src/app/services/cron.ts b/src/app/services/cron.ts index 53c23b38a..a4e6cdf38 100644 --- a/src/app/services/cron.ts +++ b/src/app/services/cron.ts @@ -97,18 +97,17 @@ export class CoreCronDelegate { * @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 { + protected async checkAndExecuteHandler(name: string, force?: boolean, siteId?: string): Promise { if (!this.handlers[name] || !this.handlers[name].execute) { // Invalid handler. const message = `Cannot execute handler because is invalid: ${name}`; this.logger.debug(message); - return Promise.reject(new CoreError(message)); + throw new CoreError(message); } const usesNetwork = this.handlerUsesNetwork(name); const isSync = !force && this.isHandlerSync(name); - let promise; if (usesNetwork && !CoreApp.instance.isOnline()) { // Offline, stop executing. @@ -116,47 +115,46 @@ export class CoreCronDelegate { this.logger.debug(message); this.stopHandler(name); - return Promise.reject(new CoreError(message)); + throw new CoreError(message); } if (isSync) { // Check network connection. - promise = CoreConfig.instance.get(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, false) - .then((syncOnlyOnWifi) => !syncOnlyOnWifi || CoreApp.instance.isWifi()); - } else { - promise = Promise.resolve(true); - } + const syncOnlyOnWifi = await CoreConfig.instance.get(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, false); - return promise.then((execute: boolean) => { - if (!execute) { + if (syncOnlyOnWifi && !CoreApp.instance.isWifi()) { // Cannot execute in this network connection, retry soon. const message = `Cannot execute handler because device is using limited connection: ${name}`; this.logger.debug(message); this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); - return Promise.reject(new CoreError(message)); + throw new CoreError(message); } + } + + // Add the execution to the queue. + this.queuePromise = CoreUtils.instance.ignoreErrors(this.queuePromise).then(async () => { + try { + await this.executeHandler(name, force, siteId); - // Add the execution to the queue. - this.queuePromise = this.queuePromise.catch(() => { - // Ignore errors in previous handlers. - }).then(() => 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) => { + await CoreUtils.instance.ignoreErrors(this.setHandlerLastExecutionTime(name, Date.now())); + + this.scheduleNextExecution(name); + + return; + } catch (error) { // Handler call failed. Retry soon. const message = `Execution of handler '${name}' failed.`; this.logger.error(message, error); this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); - return Promise.reject(new CoreError(message)); - })); - - return this.queuePromise; + throw new CoreError(message); + } }); + + return this.queuePromise; } /** @@ -172,7 +170,7 @@ export class CoreCronDelegate { 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(() => { + Promise.resolve(this.handlers[name].execute!(siteId, force)).then(resolve).catch(reject).finally(() => { clearTimeout(cancelTimeout); }); @@ -192,7 +190,7 @@ export class CoreCronDelegate { * @return Promise resolved if all handlers are executed successfully, rejected otherwise. */ async forceSyncExecution(siteId?: string): Promise { - const promises = []; + const promises: Promise[] = []; for (const name in this.handlers) { if (this.isHandlerManualSync(name)) { @@ -208,11 +206,11 @@ export class CoreCronDelegate { * 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 name 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 { + forceCronHandlerExecution(name: string, siteId?: string): Promise { const handler = this.handlers[name]; // Mark the handler as running (it might be running already). @@ -240,7 +238,7 @@ export class CoreCronDelegate { // 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(); + const handlerInterval = this.handlers[name].getInterval!(); if (!handlerInterval) { return CoreCronDelegate.DEFAULT_INTERVAL; @@ -288,12 +286,12 @@ export class CoreCronDelegate { * @return True if handler uses network or not defined, false otherwise. */ protected handlerUsesNetwork(name: string): boolean { - if (!this.handlers[name] || !this.handlers[name].usesNetwork) { + if (!this.handlers[name] || this.handlers[name].usesNetwork) { // Invalid, return default. return true; } - return this.handlers[name].usesNetwork(); + return this.handlers[name].usesNetwork!(); } /** @@ -338,7 +336,7 @@ export class CoreCronDelegate { return this.isHandlerSync(name); } - return this.handlers[name].canManualSync(); + return this.handlers[name].canManualSync!(); } /** @@ -353,7 +351,7 @@ export class CoreCronDelegate { return true; } - return this.handlers[name].isSync(); + return this.handlers[name].isSync!(); } /** @@ -385,10 +383,10 @@ export class CoreCronDelegate { * 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. + * @param timeToNextExecution Time (in milliseconds). If not supplied it will be calculated. + * @return Promise resolved when done. */ - protected scheduleNextExecution(name: string, time?: number): void { + protected async scheduleNextExecution(name: string, timeToNextExecution?: number): Promise { if (!this.handlers[name]) { // Invalid handler. return; @@ -398,33 +396,24 @@ export class CoreCronDelegate { return; } - let promise; - - if (time) { - promise = Promise.resolve(time); - } else { + if (!timeToNextExecution) { // 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; + const lastExecution = await this.getHandlerLastExecutionTime(name); - return nextExecution - Date.now(); - }); + const interval = this.getHandlerInterval(name); + + timeToNextExecution = lastExecution + interval - 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.logger.debug(`Scheduling next execution of handler '${name}' in '${timeToNextExecution}' ms`); + if (timeToNextExecution < 0) { + timeToNextExecution = 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); - }); + this.handlers[name].timeout = window.setTimeout(() => { + delete this.handlers[name].timeout; + CoreUtils.instance.ignoreErrors(this.checkAndExecuteHandler(name)); + }, timeToNextExecution); } /** diff --git a/src/app/services/file-helper.ts b/src/app/services/file-helper.ts index 31ac0aa8c..ae7842ebd 100644 --- a/src/app/services/file-helper.ts +++ b/src/app/services/file-helper.ts @@ -44,11 +44,17 @@ export class CoreFileHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Resolved on success. */ - async downloadAndOpenFile(file: CoreWSExternalFile, component: string, componentId: string | number, state?: string, - onProgress?: CoreFileHelperOnProgress, siteId?: string): Promise { + async downloadAndOpenFile( + file: CoreWSExternalFile, + component: string, + componentId: string | number, + state?: string, + onProgress?: CoreFileHelperOnProgress, + siteId?: string, + ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); - const fileUrl = this.getFileUrl(file); + const fileUrl = file.fileurl; const timemodified = this.getFileTimemodified(file); if (!this.isOpenableInApp(file)) { @@ -111,70 +117,76 @@ export class CoreFileHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Resolved with the URL to use on success. */ - protected downloadFileIfNeeded(file: CoreWSExternalFile, fileUrl: string, component?: string, componentId?: string | number, - timemodified?: number, state?: string, onProgress?: CoreFileHelperOnProgress, siteId?: string): Promise { + protected async downloadFileIfNeeded( + file: CoreWSExternalFile, + fileUrl: string, + component?: string, + componentId?: string | number, + timemodified?: number, + state?: string, + onProgress?: CoreFileHelperOnProgress, + siteId?: string, + ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); - return CoreSites.instance.getSite(siteId).then((site) => 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); + const site = await CoreSites.instance.getSite(siteId); + const fixedUrl = await site.checkAndFixPluginfileURL(fileUrl); + + if (!CoreFile.instance.isAvailable()) { + // Use the online URL. + return fixedUrl; + } + + if (!state) { + // Calculate the state. + state = await CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified); + } + + // 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. + throw new CoreError(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 }); + } + + try { + await CoreFilepool.instance.shouldDownloadBeforeOpen(fixedUrl, file.filesize || 0); + } catch (error) { + // 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); } - return promise.then((state) => { - // The file system is available. - const isWifi = CoreApp.instance.isWifi(); - const isOnline = CoreApp.instance.isOnline(); + 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); + } + } - 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(new CoreError(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. + // Download the file first. + if (state == CoreConstants.DOWNLOADING) { + // It's already downloading, stop. return fixedUrl; } - }); + + // Download and then return the local URL. + return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); + } } /** @@ -189,29 +201,37 @@ export class CoreFileHelperProvider { * @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: ProgressEvent) => void, file?: CoreWSExternalFile, siteId?: string): Promise { + async downloadFile( + fileUrl: string, + component?: string, + componentId?: string | number, + timemodified?: number, + onProgress?: (event: ProgressEvent) => void, + file?: CoreWSExternalFile, + siteId?: string, + ): Promise { 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(new CoreError(Translate.instance.instant('core.cannotdownloadfiles'))); + const site = await CoreSites.instance.getSite(siteId); + + if (!site.canDownloadFiles()) { + throw new CoreError(Translate.instance.instant('core.cannotdownloadfiles')); + } + + try { + return await 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. + const state = await CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified); + + if (this.isStateDownloaded(state)) { + return CoreFilepool.instance.getInternalUrlByUrl(siteId, fileUrl); + } else { + throw error; } - - 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. - CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified).then((state) => { - if (this.isStateDownloaded(state)) { - return CoreFilepool.instance.getInternalUrlByUrl(siteId, fileUrl); - } else { - return Promise.reject(error); - } - }), - ); - }); + } } /** @@ -220,7 +240,7 @@ export class CoreFileHelperProvider { * @param file The file. * @deprecated since 3.9.5. Get directly the fileurl instead. */ - getFileUrl(file: CoreWSExternalFile): string { + getFileUrl(file: CoreWSExternalFile): string | undefined { return file.fileurl; } @@ -337,11 +357,15 @@ export class CoreFileHelperProvider { * @return bool. */ isOpenableInApp(file: {filename?: string; name?: string}): boolean { - const re = /(?:\.([^.]+))?$/; + const regex = /(?:\.([^.]+))?$/; + const regexResult = regex.exec(file.filename || file.name || ''); - const ext = re.exec(file.filename || file.name)[1]; + if (!regexResult || !regexResult[1]) { + // Couldn't find the extension. Assume it's openable. + return true; + } - return !this.isFileTypeExcludedInApp(ext); + return !this.isFileTypeExcludedInApp(regexResult[1]); } /** @@ -365,7 +389,7 @@ export class CoreFileHelperProvider { */ isFileTypeExcludedInApp(fileType: string): boolean { const currentSite = CoreSites.instance.getCurrentSite(); - const fileTypeExcludeList = currentSite && currentSite.getStoredConfig('tool_mobile_filetypeexclusionlist'); + const fileTypeExcludeList = currentSite?.getStoredConfig('tool_mobile_filetypeexclusionlist'); if (!fileTypeExcludeList) { return false; diff --git a/src/app/services/file-session.ts b/src/app/services/file-session.ts index b03cd24a6..9cbdeadc9 100644 --- a/src/app/services/file-session.ts +++ b/src/app/services/file-session.ts @@ -85,7 +85,7 @@ export class CoreFileSessionProvider { * @param id File area identifier. * @param siteId Site ID. If not defined, current site. */ - protected initFileArea(component: string, id: string | number, siteId?: string): void { + protected initFileArea(component: string, id: string | number, siteId: string): void { if (!this.files[siteId]) { this.files[siteId] = {}; } diff --git a/src/app/services/file.ts b/src/app/services/file.ts index c674764a7..7ad27e5cf 100644 --- a/src/app/services/file.ts +++ b/src/app/services/file.ts @@ -117,25 +117,25 @@ export class CoreFileProvider { * * @return Promise to be resolved when the initialization is finished. */ - init(): Promise { + async init(): Promise { if (this.initialized) { - return Promise.resolve(); + return; } - return Platform.instance.ready().then(() => { - if (CoreApp.instance.isAndroid()) { - this.basePath = File.instance.externalApplicationStorageDirectory || this.basePath; - } else if (CoreApp.instance.isIOS()) { - this.basePath = File.instance.documentsDirectory || this.basePath; - } else if (!this.isAvailable() || this.basePath === '') { - this.logger.error('Error getting device OS.'); + await Platform.instance.ready(); - return Promise.reject(new CoreError('Error getting device OS to initialize file system.')); - } + if (CoreApp.instance.isAndroid()) { + this.basePath = File.instance.externalApplicationStorageDirectory || this.basePath; + } else if (CoreApp.instance.isIOS()) { + this.basePath = File.instance.documentsDirectory || this.basePath; + } else if (!this.isAvailable() || this.basePath === '') { + this.logger.error('Error getting device OS.'); - this.initialized = true; - this.logger.debug('FS initialized: ' + this.basePath); - }); + return Promise.reject(new CoreError('Error getting device OS to initialize file system.')); + } + + this.initialized = true; + this.logger.debug('FS initialized: ' + this.basePath); } /** @@ -194,8 +194,12 @@ export class CoreFileProvider { * @param base Base path to create the dir/file in. If not set, use basePath. * @return Promise to be resolved when the dir/file is created. */ - protected async create(isDirectory: boolean, path: string, failIfExists?: boolean, base?: string): - Promise { + protected async create( + isDirectory: boolean, + path: string, + failIfExists?: boolean, + base?: string, + ): Promise { await this.init(); // Remove basePath if it's in the path. @@ -340,17 +344,19 @@ export class CoreFileProvider { * @return Promise to be resolved when the size is calculated. */ protected getSize(entry: DirectoryEntry | FileEntry): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (this.isDirectoryEntry(entry)) { const directoryReader = entry.createReader(); - directoryReader.readEntries((entries: (DirectoryEntry | FileEntry)[]) => { - const promises = []; + directoryReader.readEntries(async (entries: (DirectoryEntry | FileEntry)[]) => { + const promises: Promise[] = []; for (let i = 0; i < entries.length; i++) { promises.push(this.getSize(entries[i])); } - Promise.all(promises).then((sizes) => { + try { + const sizes = await Promise.all(promises); + let directorySize = 0; for (let i = 0; i < sizes.length; i++) { const fileSize = Number(sizes[i]); @@ -362,7 +368,9 @@ export class CoreFileProvider { directorySize += fileSize; } resolve(directorySize); - }, reject); + } catch (error) { + reject(error); + } }, reject); } else { entry.file((file) => { @@ -469,7 +477,7 @@ export class CoreFileProvider { const parsed = CoreTextUtils.instance.parseJSON(text, null); if (parsed == null && text != null) { - return Promise.reject(new CoreError('Error parsing JSON file: ' + path)); + throw new CoreError('Error parsing JSON file: ' + path); } return parsed; @@ -494,7 +502,7 @@ export class CoreFileProvider { const reader = new FileReader(); reader.onloadend = (event): void => { - if (event.target.result !== undefined && event.target.result !== null) { + if (event.target?.result !== undefined && event.target.result !== null) { if (format == CoreFileProvider.FORMATJSON) { // Convert to object. const parsed = CoreTextUtils.instance.parseJSON( event.target.result, null); @@ -507,7 +515,7 @@ export class CoreFileProvider { } else { resolve(event.target.result); } - } else if (event.target.error !== undefined && event.target.error !== null) { + } else if (event.target?.error !== undefined && event.target.error !== null) { reject(event.target.error); } else { reject({ code: null, message: 'READER_ONLOADEND_ERR' }); @@ -550,25 +558,27 @@ export class CoreFileProvider { * @param append Whether to append the data to the end of the file. * @return Promise to be resolved when the file is written. */ - writeFile(path: string, data: string | Blob, append?: boolean): Promise { - return this.init().then(() => { - // Remove basePath if it's in the path. - path = this.removeStartingSlash(path.replace(this.basePath, '')); - this.logger.debug('Write file: ' + path); + async writeFile(path: string, data: string | Blob, append?: boolean): Promise { + await this.init(); - // Create file (and parent folders) to prevent errors. - return this.createFile(path).then((fileEntry) => { - if (this.isHTMLAPI && !CoreApp.instance.isDesktop() && - (typeof data == 'string' || data.toString() == '[object ArrayBuffer]')) { - // We need to write Blobs. - const type = CoreMimetypeUtils.instance.getMimeType(CoreMimetypeUtils.instance.getFileExtension(path)); - data = new Blob([data], { type: type || 'text/plain' }); - } + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Write file: ' + path); - return File.instance.writeFile(this.basePath, path, data, { replace: !append, append: !!append }) - .then(() => fileEntry); - }); - }); + // Create file (and parent folders) to prevent errors. + const fileEntry = await this.createFile(path); + + if (this.isHTMLAPI && !CoreApp.instance.isDesktop() && + (typeof data == 'string' || data.toString() == '[object ArrayBuffer]')) { + // We need to write Blobs. + const extension = CoreMimetypeUtils.instance.getFileExtension(path); + const type = extension ? CoreMimetypeUtils.instance.getMimeType(extension) : ''; + data = new Blob([data], { type: type || 'text/plain' }); + } + + await File.instance.writeFile(this.basePath, path, data, { replace: !append, append: !!append }); + + return fileEntry; } /** @@ -583,8 +593,13 @@ export class CoreFileProvider { * @param append Whether to append the data to the end of the file. * @return Promise resolved when done. */ - async writeFileDataInFile(file: Blob, path: string, onProgress?: CoreFileProgressFunction, offset: number = 0, - append?: boolean): Promise { + async writeFileDataInFile( + file: Blob, + path: string, + onProgress?: CoreFileProgressFunction, + offset: number = 0, + append?: boolean, + ): Promise { offset = offset || 0; try { @@ -675,16 +690,18 @@ export class CoreFileProvider { * * @return Promise to be resolved when the base path is retrieved. */ - getBasePathToDownload(): Promise { - return this.init().then(() => { - if (CoreApp.instance.isIOS()) { - // In iOS we want the internal URL (cdvfile://localhost/persistent/...). - return File.instance.resolveDirectoryUrl(this.basePath).then((dirEntry) => dirEntry.toInternalURL()); - } else { - // In the other platforms we use the basePath as it is (file://...). - return this.basePath; - } - }); + async getBasePathToDownload(): Promise { + await this.init(); + + if (CoreApp.instance.isIOS()) { + // In iOS we want the internal URL (cdvfile://localhost/persistent/...). + const dirEntry = await File.instance.resolveDirectoryUrl(this.basePath); + + return dirEntry.toInternalURL(); + } else { + // In the other platforms we use the basePath as it is (file://...). + return this.basePath; + } } /** @@ -773,18 +790,22 @@ export class CoreFileProvider { * try to create it (slower). * @return Promise resolved when the entry is copied. */ - protected async copyOrMoveFileOrDir(from: string, to: string, isDir?: boolean, copy?: boolean, destDirExists?: boolean): - Promise { + protected async copyOrMoveFileOrDir( + from: string, + to: string, + isDir?: boolean, + copy?: boolean, + destDirExists?: boolean, + ): Promise { const fileIsInAppFolder = this.isPathInAppFolder(from); if (!fileIsInAppFolder) { return this.copyOrMoveExternalFile(from, to, copy); } - const moveCopyFn: (path: string, dirName: string, newPath: string, newDirName: string) => - Promise = copy ? - (isDir ? File.instance.copyDir.bind(File.instance) : File.instance.copyFile.bind(File.instance)) : - (isDir ? File.instance.moveDir.bind(File.instance) : File.instance.moveFile.bind(File.instance)); + const moveCopyFn: MoveCopyFunction = copy ? + (isDir ? File.instance.copyDir.bind(File.instance) : File.instance.copyFile.bind(File.instance)) : + (isDir ? File.instance.moveDir.bind(File.instance) : File.instance.moveFile.bind(File.instance)); await this.init(); @@ -880,6 +901,8 @@ export class CoreFileProvider { if (path.indexOf(this.basePath) > -1) { return path.replace(this.basePath, ''); } + + return path; } /** @@ -892,33 +915,31 @@ export class CoreFileProvider { * @param recreateDir Delete the dest directory before unzipping. Defaults to true. * @return Promise resolved when the file is unzipped. */ - unzipFile(path: string, destFolder?: string, onProgress?: (progress: ProgressEvent) => void, recreateDir: boolean = true): - Promise { + async unzipFile( + path: string, + destFolder?: string, + onProgress?: (progress: ProgressEvent) => void, + recreateDir: boolean = true, + ): Promise { // Get the source file. - let fileEntry: FileEntry; + const fileEntry = await this.getFile(path); - return this.getFile(path).then((fe) => { - fileEntry = fe; + if (destFolder && recreateDir) { + // Make sure the dest dir doesn't exist already. + await CoreUtils.instance.ignoreErrors(this.removeDir(destFolder)); - if (destFolder && recreateDir) { - // Make sure the dest dir doesn't exist already. - return this.removeDir(destFolder).catch(() => { - // Ignore errors. - }).then(() => - // Now create the dir, otherwise if any of the ancestor dirs doesn't exist the unzip would fail. - this.createDir(destFolder), - ); - } - }).then(() => { - // If destFolder is not set, use same location as ZIP file. We need to use absolute paths (including basePath). - destFolder = this.addBasePathIfNeeded(destFolder || CoreMimetypeUtils.instance.removeExtension(path)); + // Now create the dir, otherwise if any of the ancestor dirs doesn't exist the unzip would fail. + await this.createDir(destFolder); + } - return Zip.instance.unzip(fileEntry.toURL(), destFolder, onProgress); - }).then((result) => { - if (result == -1) { - return Promise.reject(new CoreError('Unzip failed.')); - } - }); + // If destFolder is not set, use same location as ZIP file. We need to use absolute paths (including basePath). + destFolder = this.addBasePathIfNeeded(destFolder || CoreMimetypeUtils.instance.removeExtension(path)); + + const result = await Zip.instance.unzip(fileEntry.toURL(), destFolder, onProgress); + + if (result == -1) { + throw new CoreError('Unzip failed.'); + } } /** @@ -999,22 +1020,22 @@ export class CoreFileProvider { * @param copy True to copy, false to move. * @return Promise resolved when the entry is copied/moved. */ - protected copyOrMoveExternalFile(from: string, to: string, copy?: boolean): Promise { + protected async copyOrMoveExternalFile(from: string, to: string, copy?: boolean): Promise { // Get the file to copy/move. - return this.getExternalFile(from).then((fileEntry) => { - // Create the destination dir if it doesn't exist. - const dirAndFile = this.getFileAndDirectoryFromPath(to); + const fileEntry = await this.getExternalFile(from); - return this.createDir(dirAndFile.directory).then((dirEntry) => - // Now copy/move the file. - new Promise((resolve, reject): void => { - if (copy) { - fileEntry.copyTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); - } else { - fileEntry.moveTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); - } - }), - ); + // Create the destination dir if it doesn't exist. + const dirAndFile = this.getFileAndDirectoryFromPath(to); + + const dirEntry = await this.createDir(dirAndFile.directory); + + // Now copy/move the file. + return new Promise((resolve, reject): void => { + if (copy) { + fileEntry.copyTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); + } else { + fileEntry.moveTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); + } }); } @@ -1048,9 +1069,11 @@ export class CoreFileProvider { * @param defaultExt Default extension to use if no extension found in the file. * @return Promise resolved with the unique file name. */ - getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt?: string): Promise { + async getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt?: string): Promise { // Get existing files in the folder. - return this.getDirectoryContents(dirPath).then((entries) => { + try { + const entries = await this.getDirectoryContents(dirPath); + const files = {}; let num = 1; let fileNameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(fileName); @@ -1058,7 +1081,8 @@ export class CoreFileProvider { // Clean the file name. fileNameWithoutExtension = CoreTextUtils.instance.removeSpecialCharactersForFiles( - CoreTextUtils.instance.decodeURIComponent(fileNameWithoutExtension)); + CoreTextUtils.instance.decodeURIComponent(fileNameWithoutExtension), + ); // Index the files by name. entries.forEach((entry) => { @@ -1086,10 +1110,10 @@ export class CoreFileProvider { // Ask the user what he wants to do. return newName; } - }).catch(() => + } catch (error) { // Folder doesn't exist, name is unique. Clean it and return it. - CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName)), - ); + return CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName)); + } } /** @@ -1119,7 +1143,7 @@ export class CoreFileProvider { } const filesMap: {[fullPath: string]: FileEntry} = {}; - const promises = []; + const promises: Promise[] = []; // Index the received files by fullPath and ignore the invalid ones. files.forEach((file) => { @@ -1219,3 +1243,5 @@ export class CoreFileProvider { } export class CoreFile extends makeSingleton(CoreFileProvider) {} + +type MoveCopyFunction = (path: string, dirName: string, newPath: string, newDirName: string) => Promise; diff --git a/src/app/services/filepool.ts b/src/app/services/filepool.ts index b910c9334..dd602a123 100644 --- a/src/app/services/filepool.ts +++ b/src/app/services/filepool.ts @@ -239,7 +239,7 @@ export class CoreFilepoolProvider { protected logger: CoreLogger; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. - protected queueState: string; + protected queueState = CoreFilepoolProvider.QUEUE_PAUSED; protected urlAttributes: RegExp[] = [ new RegExp('(\\?|&)token=([A-Za-z0-9]*)'), new RegExp('(\\?|&)forcedownload=[0-1]'), @@ -264,16 +264,23 @@ export class CoreFilepoolProvider { CoreSites.instance.registerSiteSchema(this.siteSchema); - CoreInit.instance.ready().then(() => { - // Waiting for the app to be ready to start processing the queue. - this.checkQueueProcessing(); + this.init(); + } - // Start queue when device goes online. - Network.instance.onConnect().subscribe(() => { - // Execute the callback in the Angular zone, so change detection doesn't stop working. - NgZone.instance.run(() => { - this.checkQueueProcessing(); - }); + /** + * Init some properties. + */ + protected async init(): Promise { + // Waiting for the app to be ready to start processing the queue. + await CoreInit.instance.ready(); + + this.checkQueueProcessing(); + + // Start queue when device goes online. + Network.instance.onConnect().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.checkQueueProcessing(); }); }); } @@ -358,7 +365,7 @@ export class CoreFilepoolProvider { * @param data Additional information to store about the file (timemodified, url, ...). See FILES_TABLE schema. * @return Promise resolved on success. */ - protected async addFileToPool(siteId: string, fileId: string, data: CoreFilepoolFileEntry): Promise { + protected async addFileToPool(siteId: string, fileId: string, data: Omit): Promise { const record = { fileId, ...data, @@ -410,9 +417,18 @@ export class CoreFilepoolProvider { * @param link The link to add for the file. * @return Promise resolved when the file is downloaded. */ - protected async addToQueue(siteId: string, fileId: string, url: string, priority: number, revision: number, - timemodified: number, filePath: string, onProgress?: CoreFilepoolOnProgressCallback, - options: CoreFilepoolFileOptions = {}, link?: CoreFilepoolComponentLink): Promise { + protected async addToQueue( + siteId: string, + fileId: string, + url: string, + priority: number, + revision: number, + timemodified: number, + filePath?: string, + onProgress?: CoreFilepoolOnProgressCallback, + options: CoreFilepoolFileOptions = {}, + link?: CoreFilepoolComponentLink, + ): Promise { await this.dbReady; this.logger.debug(`Adding ${fileId} to the queue`); @@ -454,9 +470,19 @@ export class CoreFilepoolProvider { * @param alreadyFixed Whether the URL has already been fixed. * @return Resolved on success. */ - async addToQueueByUrl(siteId: string, fileUrl: string, component?: string, componentId?: string | number, - timemodified: number = 0, filePath?: string, onProgress?: CoreFilepoolOnProgressCallback, priority: number = 0, - options: CoreFilepoolFileOptions = {}, revision?: number, alreadyFixed?: boolean): Promise { + async addToQueueByUrl( + siteId: string, + fileUrl: string, + component?: string, + componentId?: string | number, + timemodified: number = 0, + filePath?: string, + onProgress?: CoreFilepoolOnProgressCallback, + priority: number = 0, + options: CoreFilepoolFileOptions = {}, + revision?: number, + alreadyFixed?: boolean, + ): Promise { await this.dbReady; if (!CoreFile.instance.isAvailable()) { @@ -468,16 +494,14 @@ export class CoreFilepoolProvider { throw new CoreError('Site doesn\'t allow downloading files.'); } - let file: CoreWSExternalFile; - if (alreadyFixed) { - // Already fixed, if we reached here it means it can be downloaded. - file = { fileurl: fileUrl }; - } else { - file = await this.fixPluginfileURL(siteId, fileUrl); + if (!alreadyFixed) { + // Fix the URL and use the fixed data. + const file = await this.fixPluginfileURL(siteId, fileUrl); + + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; } - fileUrl = file.fileurl; - timemodified = file.timemodified || timemodified; revision = revision || this.getRevisionFromUrl(fileUrl); const fileId = this.getFileIdByUrl(fileUrl); @@ -489,71 +513,68 @@ export class CoreFilepoolProvider { // Retrieve the queue deferred now if it exists. // This is to prevent errors if file is removed from queue while we're checking if the file is in queue. const queueDeferred = this.getQueueDeferred(siteId, fileId, false, onProgress); + let entry: CoreFilepoolQueueEntry; - return this.hasFileInQueue(siteId, fileId).then((entry: CoreFilepoolQueueEntry) => { - const newData: CoreFilepoolQueueDBEntry = {}; - let foundLink = false; - - if (entry) { - // We already have the file in queue, we update the priority and links. - if (entry.priority < priority) { - newData.priority = priority; - } - if (revision && entry.revision !== revision) { - newData.revision = revision; - } - if (timemodified && entry.timemodified !== timemodified) { - newData.timemodified = timemodified; - } - if (filePath && entry.path !== filePath) { - newData.path = filePath; - } - if (entry.isexternalfile !== options.isexternalfile && (entry.isexternalfile || options.isexternalfile)) { - newData.isexternalfile = options.isexternalfile; - } - if (entry.repositorytype !== options.repositorytype && (entry.repositorytype || options.repositorytype)) { - newData.repositorytype = options.repositorytype; - } - - if (link) { - // We need to add the new link if it does not exist yet. - if (entry.linksUnserialized && entry.linksUnserialized.length) { - foundLink = entry.linksUnserialized.some((fileLink) => - fileLink.component == link.component && fileLink.componentId == link.componentId); - } - - if (!foundLink) { - const links = entry.linksUnserialized || []; - links.push(link); - newData.links = JSON.stringify(links); - } - } - - if (Object.keys(newData).length) { - // Update only when required. - this.logger.debug(`Updating file ${fileId} which is already in queue`); - - return this.appDB.updateRecords(CoreFilepoolProvider.QUEUE_TABLE, newData, primaryKey).then(() => - this.getQueuePromise(siteId, fileId, true, onProgress)); - } - - this.logger.debug(`File ${fileId} already in queue and does not require update`); - if (queueDeferred) { - // If we were able to retrieve the queue deferred before, we use that one. - return queueDeferred.promise; - } else { - // Create a new deferred and return its promise. - return this.getQueuePromise(siteId, fileId, true, onProgress); - } - } else { - return this.addToQueue( - siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); - } - }, () => + try { + entry = await this.hasFileInQueue(siteId, fileId); + } catch (error) { // Unsure why we could not get the record, let's add to the queue anyway. - this.addToQueue( - siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link), - ); + return this.addToQueue(siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); + } + + const newData: Partial = {}; + let foundLink = false; + + // We already have the file in queue, we update the priority and links. + if (!entry.priority || entry.priority < priority) { + newData.priority = priority; + } + if (revision && entry.revision !== revision) { + newData.revision = revision; + } + if (timemodified && entry.timemodified !== timemodified) { + newData.timemodified = timemodified; + } + if (filePath && entry.path !== filePath) { + newData.path = filePath; + } + if (entry.isexternalfile !== options.isexternalfile && (entry.isexternalfile || options.isexternalfile)) { + newData.isexternalfile = options.isexternalfile; + } + if (entry.repositorytype !== options.repositorytype && (entry.repositorytype || options.repositorytype)) { + newData.repositorytype = options.repositorytype; + } + + if (link) { + // We need to add the new link if it does not exist yet. + if (entry.linksUnserialized && entry.linksUnserialized.length) { + foundLink = entry.linksUnserialized.some((fileLink) => + fileLink.component == link.component && fileLink.componentId == link.componentId); + } + + if (!foundLink) { + const links = entry.linksUnserialized || []; + links.push(link); + newData.links = JSON.stringify(links); + } + } + + if (Object.keys(newData).length) { + // Update only when required. + this.logger.debug(`Updating file ${fileId} which is already in queue`); + + return this.appDB.updateRecords(CoreFilepoolProvider.QUEUE_TABLE, newData, primaryKey).then(() => + this.getQueuePromise(siteId, fileId, true, onProgress)); + } + + this.logger.debug(`File ${fileId} already in queue and does not require update`); + if (queueDeferred) { + // If we were able to retrieve the queue deferred before, we use that one. + return queueDeferred.promise; + } else { + // Create a new deferred and return its promise. + return this.getQueuePromise(siteId, fileId, true, onProgress); + } } /** @@ -571,13 +592,32 @@ export class CoreFilepoolProvider { * @param revision File revision. If not defined, it will be calculated using the URL. * @return Promise resolved when the file is downloaded. */ - protected async addToQueueIfNeeded(siteId: string, fileUrl: string, component: string, componentId?: string | number, - timemodified: number = 0, checkSize: boolean = true, downloadUnknown?: boolean, options: CoreFilepoolFileOptions = {}, - revision?: number): Promise { + protected async addToQueueIfNeeded( + siteId: string, + fileUrl: string, + component?: string, + componentId?: string | number, + timemodified: number = 0, + checkSize: boolean = true, + downloadUnknown?: boolean, + options: CoreFilepoolFileOptions = {}, + revision?: number, + ): Promise { if (!checkSize) { // No need to check size, just add it to the queue. - await this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options, - revision, true); + await this.addToQueueByUrl( + siteId, + fileUrl, + component, + componentId, + timemodified, + undefined, + undefined, + 0, + options, + revision, + true, + ); } let size: number; @@ -603,14 +643,20 @@ export class CoreFilepoolProvider { } // Check if the file should be downloaded. - if (sizeUnknown) { - if (downloadUnknown && isWifi) { - await this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, - 0, options, revision, true); - } - } else if (this.shouldDownload(size)) { - await this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, - options, revision, true); + if ((sizeUnknown && downloadUnknown && isWifi) || (!sizeUnknown && this.shouldDownload(size))) { + await this.addToQueueByUrl( + siteId, + fileUrl, + component, + componentId, + timemodified, + undefined, + undefined, + 0, + options, + revision, + true, + ); } } @@ -699,12 +745,10 @@ export class CoreFilepoolProvider { * @param componentId An ID to use in conjunction with the component. * @return Link, null if nothing to link. */ - protected createComponentLink(component: string, componentId?: string | number): CoreFilepoolComponentLink | null { + protected createComponentLink(component?: string, componentId?: string | number): CoreFilepoolComponentLink | undefined { if (typeof component != 'undefined' && component != null) { return { component, componentId: this.fixComponentId(componentId) }; } - - return null; } /** @@ -714,7 +758,7 @@ export class CoreFilepoolProvider { * @param componentId An ID to use in conjunction with the component. * @return Links. */ - protected createComponentLinks(component: string, componentId?: string | number): CoreFilepoolComponentLink[] { + protected createComponentLinks(component?: string, componentId?: string | number): CoreFilepoolComponentLink[] { const link = this.createComponentLink(component, componentId); return link ? [link] : []; @@ -772,12 +816,18 @@ export class CoreFilepoolProvider { * @param poolFileObject When set, the object will be updated, a new entry will not be created. * @return Resolved with internal URL on success, rejected otherwise. */ - protected async downloadForPoolByUrl(siteId: string, fileUrl: string, options: CoreFilepoolFileOptions = {}, filePath?: string, - onProgress?: CoreFilepoolOnProgressCallback, poolFileObject?: CoreFilepoolFileEntry): Promise { + protected async downloadForPoolByUrl( + siteId: string, + fileUrl: string, + options: CoreFilepoolFileOptions = {}, + filePath?: string, + onProgress?: CoreFilepoolOnProgressCallback, + poolFileObject?: CoreFilepoolFileEntry, + ): Promise { const fileId = this.getFileIdByUrl(fileUrl); const extension = CoreMimetypeUtils.instance.guessExtensionFromUrl(fileUrl); const addExtension = typeof filePath == 'undefined'; - filePath = filePath || (await this.getFilePath(siteId, fileId, extension)); + const path = filePath || (await this.getFilePath(siteId, fileId, extension)); if (poolFileObject && poolFileObject.fileId !== fileId) { this.logger.error('Invalid object to update passed'); @@ -785,7 +835,7 @@ export class CoreFilepoolProvider { throw new CoreError('Invalid object to update passed.'); } - const downloadId = this.getFileDownloadId(fileUrl, filePath); + const downloadId = this.getFileDownloadId(fileUrl, path); if (this.filePromises[siteId] && this.filePromises[siteId][downloadId]) { // There's already a download ongoing for this file in this location, return the promise. @@ -799,22 +849,21 @@ export class CoreFilepoolProvider { throw new CoreError('Site doesn\'t allow downloading files.'); } - const entry = await CoreWS.instance.downloadFile(fileUrl, filePath, addExtension, onProgress); + const entry = await CoreWS.instance.downloadFile(fileUrl, path, addExtension, onProgress); const fileEntry = entry; await CorePluginFile.instance.treatDownloadedFile(fileUrl, fileEntry, siteId, onProgress); - const data: CoreFilepoolFileEntry = poolFileObject || {}; - data.downloadTime = Date.now(); - data.stale = 0; - data.url = fileUrl; - data.revision = options.revision; - data.timemodified = options.timemodified; - data.isexternalfile = options.isexternalfile ? 1 : 0; - data.repositorytype = options.repositorytype; - data.path = fileEntry.path; - data.extension = fileEntry.extension; - - await this.addFileToPool(siteId, fileId, data); + await this.addFileToPool(siteId, fileId, { + downloadTime: Date.now(), + stale: 0, + url: fileUrl, + revision: options.revision, + timemodified: options.timemodified, + isexternalfile: options.isexternalfile ? 1 : 0, + repositorytype: options.repositorytype, + path: fileEntry.path, + extension: fileEntry.extension, + }); return fileEntry.toURL(); }).finally(() => { @@ -838,9 +887,16 @@ export class CoreFilepoolProvider { * the files directly inside the filepool folder. * @return Resolved on success. */ - downloadOrPrefetchFiles(siteId: string, files: CoreWSExternalFile[], prefetch: boolean, ignoreStale?: boolean, - component?: string, componentId?: string | number, dirPath?: string): Promise { - const promises = []; + downloadOrPrefetchFiles( + siteId: string, + files: CoreWSExternalFile[], + prefetch: boolean, + ignoreStale?: boolean, + component?: string, + componentId?: string | number, + dirPath?: string, + ): Promise { + const promises: Promise[] = []; // Download files. files.forEach((file) => { @@ -850,23 +906,31 @@ export class CoreFilepoolProvider { isexternalfile: file.isexternalfile, repositorytype: file.repositorytype, }; - let path: string; + let path: string | undefined; if (dirPath) { // Calculate the path to the file. path = file.filename; - if (file.filepath !== '/') { + if (file.filepath && file.filepath !== '/') { path = file.filepath.substr(1) + path; } - path = CoreTextUtils.instance.concatenatePaths(dirPath, path); + path = CoreTextUtils.instance.concatenatePaths(dirPath, path!); } if (prefetch) { - promises.push(this.addToQueueByUrl( - siteId, url, component, componentId, timemodified, path, undefined, 0, options)); + promises.push(this.addToQueueByUrl(siteId, url, component, componentId, timemodified, path, undefined, 0, options)); } else { promises.push(this.downloadUrl( - siteId, url, ignoreStale, component, componentId, timemodified, undefined, path, options)); + siteId, + url, + ignoreStale, + component, + componentId, + timemodified, + undefined, + path, + options, + )); } }); @@ -887,9 +951,16 @@ export class CoreFilepoolProvider { * @param onProgress Function to call on progress. * @return Promise resolved when the package is downloaded. */ - protected downloadOrPrefetchPackage(siteId: string, fileList: CoreWSExternalFile[], prefetch?: boolean, component?: string, - componentId?: string | number, extra?: string, dirPath?: string, onProgress?: CoreFilepoolOnProgressCallback): - Promise { + protected downloadOrPrefetchPackage( + siteId: string, + fileList: CoreWSExternalFile[], + prefetch: boolean, + component: string, + componentId?: string | number, + extra?: string, + dirPath?: string, + onProgress?: CoreFilepoolOnProgressCallback, + ): Promise { const packageId = this.getPackageId(component, componentId); if (this.packagesPromises[siteId] && this.packagesPromises[siteId][packageId]) { @@ -901,7 +972,7 @@ export class CoreFilepoolProvider { // Set package as downloading. const promise = this.storePackageStatus(siteId, CoreConstants.DOWNLOADING, component, componentId).then(async () => { - const promises = []; + const promises: Promise[] = []; let packageLoaded = 0; fileList.forEach((file) => { @@ -910,10 +981,10 @@ export class CoreFilepoolProvider { isexternalfile: file.isexternalfile, repositorytype: file.repositorytype, }; - let path: string; + let path: string | undefined; let promise: Promise; let fileLoaded = 0; - let onFileProgress: (progress: ProgressEvent) => void; + let onFileProgress: ((progress: ProgressEvent) => void) | undefined; if (onProgress) { // There's a onProgress event, create a function to receive file download progress events. @@ -934,18 +1005,36 @@ export class CoreFilepoolProvider { if (dirPath) { // Calculate the path to the file. path = file.filename; - if (file.filepath !== '/') { + if (file.filepath && file.filepath !== '/') { path = file.filepath.substr(1) + path; } - path = CoreTextUtils.instance.concatenatePaths(dirPath, path); + path = CoreTextUtils.instance.concatenatePaths(dirPath, path!); } if (prefetch) { promise = this.addToQueueByUrl( - siteId, fileUrl, component, componentId, file.timemodified, path, undefined, 0, options); + siteId, + fileUrl, + component, + componentId, + file.timemodified, + path, + undefined, + 0, + options, + ); } else { promise = this.downloadUrl( - siteId, fileUrl, false, component, componentId, file.timemodified, onFileProgress, path, options); + siteId, + fileUrl, + false, + component, + componentId, + file.timemodified, + onFileProgress, + path, + options, + ); } // Using undefined for success & fail will pass the success/failure to the parent promise. @@ -956,6 +1045,8 @@ export class CoreFilepoolProvider { await Promise.all(promises); // Success prefetching, store package as downloaded. await this.storePackageStatus(siteId, CoreConstants.DOWNLOADED, component, componentId, extra); + + return; } catch (error) { // Error downloading, go back to previous status and reject the promise. await this.setPackagePreviousStatus(siteId, component, componentId); @@ -985,8 +1076,15 @@ export class CoreFilepoolProvider { * @param onProgress Function to call on progress. * @return Promise resolved when all files are downloaded. */ - downloadPackage(siteId: string, fileList: CoreWSExternalFile[], component: string, componentId?: string | number, - extra?: string, dirPath?: string, onProgress?: CoreFilepoolOnProgressCallback): Promise { + downloadPackage( + siteId: string, + fileList: CoreWSExternalFile[], + component: string, + componentId?: string | number, + extra?: string, + dirPath?: string, + onProgress?: CoreFilepoolOnProgressCallback, + ): Promise { return this.downloadOrPrefetchPackage(siteId, fileList, false, component, componentId, extra, dirPath, onProgress); } @@ -1010,10 +1108,18 @@ export class CoreFilepoolProvider { * not force a file to be re-downloaded if it is already part of the pool. You should mark a file as stale using * invalidateFileByUrl to trigger a download. */ - async downloadUrl(siteId: string, fileUrl: string, ignoreStale?: boolean, component?: string, componentId?: string | number, - timemodified: number = 0, onProgress?: CoreFilepoolOnProgressCallback, filePath?: string, - options: CoreFilepoolFileOptions = {}, revision?: number): Promise { - let promise: Promise; + async downloadUrl( + siteId: string, + fileUrl: string, + ignoreStale?: boolean, + component?: string, + componentId?: string | number, + timemodified: number = 0, + onProgress?: CoreFilepoolOnProgressCallback, + filePath?: string, + options: CoreFilepoolFileOptions = {}, + revision?: number, + ): Promise { let alreadyDownloaded = true; if (!CoreFile.instance.isAvailable()) { @@ -1031,47 +1137,9 @@ export class CoreFilepoolProvider { const links = this.createComponentLinks(component, componentId); - return this.hasFileInPool(siteId, fileId).then((fileObject) => { - if (typeof fileObject === 'undefined') { - // We do not have the file, download and add to pool. - this.notifyFileDownloading(siteId, fileId, links); - alreadyDownloaded = false; - - return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress); - } else if (this.isFileOutdated(fileObject, options.revision, options.timemodified) && - CoreApp.instance.isOnline() && !ignoreStale) { - // The file is outdated, force the download and update it. - this.notifyFileDownloading(siteId, fileId, links); - alreadyDownloaded = false; - - return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, fileObject); - } - - // Everything is fine, return the file on disk. - if (filePath) { - promise = this.getInternalUrlByPath(filePath); - } else { - promise = this.getInternalUrlById(siteId, fileId); - } - - return promise.then((url) => url, () => { - // The file was not found in the pool, weird. - this.notifyFileDownloading(siteId, fileId, links); - alreadyDownloaded = false; - - return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, fileObject); - }); - }, () => { - // The file is not in the pool just yet. - this.notifyFileDownloading(siteId, fileId, links); - alreadyDownloaded = false; - - return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress); - }).then((url) => { + const finishSuccessfulDownload = (url: string): string => { if (typeof component != 'undefined') { - this.addFileLink(siteId, fileId, component, componentId).catch(() => { - // Ignore errors. - }); + CoreUtils.instance.ignoreErrors(this.addFileLink(siteId, fileId, component, componentId)); } if (!alreadyDownloaded) { @@ -1079,11 +1147,43 @@ export class CoreFilepoolProvider { } return url; - }, (err) => { - this.notifyFileDownloadError(siteId, fileId, links); + }; - return Promise.reject(err); - }); + try { + const fileObject = await this.hasFileInPool(siteId, fileId); + let url: string; + + if (!fileObject || + this.isFileOutdated(fileObject, options.revision, options.timemodified) && + CoreApp.instance.isOnline() && + !ignoreStale + ) { + throw new CoreError('Needs to be downloaded'); + } + + // File downloaded and not outdated, return the file from disk. + if (filePath) { + url = await this.getInternalUrlByPath(filePath); + } else { + url = await this.getInternalUrlById(siteId, fileId); + } + + return finishSuccessfulDownload(url); + } catch (error) { + // The file is not downloaded or it's outdated. + this.notifyFileDownloading(siteId, fileId, links); + alreadyDownloaded = false; + + try { + const url = await this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress); + + return finishSuccessfulDownload(url); + } catch (error) { + this.notifyFileDownloadError(siteId, fileId, links); + + throw error; + } + } } /** @@ -1093,15 +1193,14 @@ export class CoreFilepoolProvider { * @return List of file urls. */ extractDownloadableFilesFromHtml(html: string): string[] { - let urls = []; + let urls: string[] = []; const element = CoreDomUtils.instance.convertToElement(html); - const elements: (HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | - HTMLTrackElement)[] = Array.from(element.querySelectorAll('a, img, audio, video, source, track')); + const elements: AnchorOrMediaElement[] = Array.from(element.querySelectorAll('a, img, audio, video, source, track')); for (let i = 0; i < elements.length; i++) { const element = elements[i]; - let url = 'href' in element ? element.href : element.src; + const url = 'href' in element ? element.href : element.src; if (url && CoreUrlUtils.instance.isDownloadableUrl(url) && urls.indexOf(url) == -1) { urls.push(url); @@ -1109,9 +1208,9 @@ export class CoreFilepoolProvider { // Treat video poster. if (element.tagName == 'VIDEO' && element.getAttribute('poster')) { - url = element.getAttribute('poster'); - if (url && CoreUrlUtils.instance.isDownloadableUrl(url) && urls.indexOf(url) == -1) { - urls.push(url); + const poster = element.getAttribute('poster'); + if (poster && CoreUrlUtils.instance.isDownloadableUrl(poster) && urls.indexOf(poster) == -1) { + urls.push(poster); } } } @@ -1186,20 +1285,20 @@ export class CoreFilepoolProvider { * @param componentId The component ID. * @return The normalised component ID. -1 when undefined was passed. */ - protected fixComponentId(componentId: string | number): string | number { + protected fixComponentId(componentId?: string | number): string | number { if (typeof componentId == 'number') { return componentId; } + if (typeof componentId == 'undefined' || componentId === null) { + return -1; + } + // Try to convert it to a number. const id = parseInt(componentId, 10); if (isNaN(id)) { // Not a number. - if (typeof componentId == 'undefined' || componentId === null) { - return -1; - } else { - return componentId; - } + return componentId; } return id; @@ -1230,8 +1329,11 @@ export class CoreFilepoolProvider { * @param componentId An ID to use in conjunction with the component. * @return Promise resolved with the files. */ - protected async getComponentFiles(db: SQLiteDB, component: string, componentId?: string | number): - Promise { + protected async getComponentFiles( + db: SQLiteDB, + component: string, + componentId?: string | number, + ): Promise { const conditions = { component, componentId: this.fixComponentId(componentId), @@ -1420,25 +1522,24 @@ export class CoreFilepoolProvider { async getFilesByComponent(siteId: string, component: string, componentId?: string | number): Promise { const db = await CoreSites.instance.getSiteDb(siteId); const items = await this.getComponentFiles(db, component, componentId); - const files = []; + const files: CoreFilepoolFileEntry[] = []; + + await Promise.all(items.map(async (item) => { + try { + const fileEntry = await db.getRecord( + CoreFilepoolProvider.FILES_TABLE, + { fileId: item.fileId }, + ); - const promises = items.map((item) => - db.getRecord(CoreFilepoolProvider.FILES_TABLE, { fileId: item.fileId }).then((fileEntry) => { if (!fileEntry) { return; } - files.push({ - url: fileEntry.url, - path: fileEntry.path, - extension: fileEntry.extension, - revision: fileEntry.revision, - timemodified: fileEntry.timemodified, - }); - }).catch(() => { - // File not found, ignore error. - })); - await Promise.all(promises); + files.push(fileEntry); + } catch (error) { + // File not found, ignore error. + } + })); return files; } @@ -1451,21 +1552,22 @@ export class CoreFilepoolProvider { * @param componentId An ID to use in conjunction with the component. * @return Promise resolved with the size on success. */ - getFilesSizeByComponent(siteId: string, component: string, componentId?: string | number): Promise { - return this.getFilesByComponent(siteId, component, componentId).then((files) => { - const promises = []; - let size = 0; + async getFilesSizeByComponent(siteId: string, component: string, componentId?: string | number): Promise { + const files = await this.getFilesByComponent(siteId, component, componentId); - files.forEach((file) => { - promises.push(CoreFile.instance.getFileSize(file.path).then((fs) => { - size += fs; - }).catch(() => { - // Ignore failures, maybe some file was deleted. - })); - }); + let size = 0; - return Promise.all(promises).then(() => size); - }); + await Promise.all(files.map(async (file) => { + try { + const fileSize = await CoreFile.instance.getFileSize(file.path); + + size += fileSize; + } catch (error) { + // Ignore failures, maybe some file was deleted. + } + })); + + return size; } /** @@ -1478,8 +1580,13 @@ export class CoreFilepoolProvider { * @param revision File revision. If not defined, it will be calculated using the URL. * @return Promise resolved with the file state. */ - async getFileStateByUrl(siteId: string, fileUrl: string, timemodified: number = 0, filePath?: string, revision?: number): - Promise { + async getFileStateByUrl( + siteId: string, + fileUrl: string, + timemodified: number = 0, + filePath?: string, + revision?: number, + ): Promise { let file: CoreWSExternalFile; try { @@ -1545,65 +1652,73 @@ export class CoreFilepoolProvider { * This handles the queue and validity of the file. If there is a local file and it's valid, return the local URL. * If the file isn't downloaded or it's outdated, return the online URL and add it to the queue to be downloaded later. */ - protected async getFileUrlByUrl(siteId: string, fileUrl: string, component: string, componentId?: string | number, - mode: string = 'url', timemodified: number = 0, checkSize: boolean = true, downloadUnknown?: boolean, - options: CoreFilepoolFileOptions = {}, revision?: number): Promise { + protected async getFileUrlByUrl( + siteId: string, + fileUrl: string, + component?: string, + componentId?: string | number, + mode: string = 'url', + timemodified: number = 0, + checkSize: boolean = true, + downloadUnknown?: boolean, + options: CoreFilepoolFileOptions = {}, + revision?: number, + ): Promise { const addToQueue = (fileUrl: string): void => { // Add the file to queue if needed and ignore errors. - this.addToQueueIfNeeded(siteId, fileUrl, component, componentId, timemodified, checkSize, - downloadUnknown, options, revision).catch(() => { - // Ignore errors. - }); + CoreUtils.instance.ignoreErrors(this.addToQueueIfNeeded( + siteId, + fileUrl, + component, + componentId, + timemodified, + checkSize, + downloadUnknown, + options, + revision, + )); }; const file = await this.fixPluginfileURL(siteId, fileUrl, timemodified); + fileUrl = file.fileurl; timemodified = file.timemodified || timemodified; revision = revision || this.getRevisionFromUrl(fileUrl); const fileId = this.getFileIdByUrl(fileUrl); - return this.hasFileInPool(siteId, fileId).then(async (entry) => { - if (typeof entry === 'undefined') { - // We do not have the file, add it to the queue, and return real URL. - addToQueue(fileUrl); + try { + const entry = await this.hasFileInPool(siteId, fileId); - return fileUrl; + if (typeof entry === 'undefined') { + throw new CoreError('File not downloaded.'); } if (this.isFileOutdated(entry, revision, timemodified) && CoreApp.instance.isOnline()) { - // The file is outdated, we add to the queue and return real URL. - addToQueue(fileUrl); - - return fileUrl; + throw new CoreError('File is outdated'); } - - try { - // We found the file entry, now look for the file on disk. - if (mode === 'src') { - return this.getInternalSrcById(siteId, fileId); - } else { - return this.getInternalUrlById(siteId, fileId); - } - } catch (error) { - // The file is not on disk. - // We could not retrieve the file, delete the entries associated with that ID. - this.logger.debug('File ' + fileId + ' not found on disk'); - this.removeFileById(siteId, fileId); - addToQueue(fileUrl); - - if (CoreApp.instance.isOnline()) { - // We still have a chance to serve the right content. - return fileUrl; - } - - throw new CoreError('File not found.'); - } - }, () => { - // We do not have the file in store yet. Add to queue and return the fixed URL. + } catch (error) { + // The file is not downloaded or it's outdated. Add to queue and return the fixed URL. addToQueue(fileUrl); return fileUrl; - }); + } + + try { + // We found the file entry, now look for the file on disk. + if (mode === 'src') { + return await this.getInternalSrcById(siteId, fileId); + } else { + return await this.getInternalUrlById(siteId, fileId); + } + } catch (error) { + // The file is not on disk. + // We could not retrieve the file, delete the entries associated with that ID. + this.logger.debug('File ' + fileId + ' not found on disk'); + this.removeFileById(siteId, fileId); + addToQueue(fileUrl); + + return fileUrl; + } } /** @@ -1770,7 +1885,7 @@ export class CoreFilepoolProvider { * @param componentId An ID to use in conjunction with the component. * @return Download promise or undefined. */ - getPackageDownloadPromise(siteId: string, component: string, componentId?: string | number): Promise { + getPackageDownloadPromise(siteId: string, component: string, componentId?: string | number): Promise | undefined { const packageId = this.getPackageId(component, componentId); if (this.packagesPromises[siteId] && this.packagesPromises[siteId][packageId]) { return this.packagesPromises[siteId][packageId]; @@ -1785,7 +1900,7 @@ export class CoreFilepoolProvider { * @param componentId An ID to use in conjunction with the component. * @return Promise resolved with the extra data. */ - getPackageExtra(siteId: string, component: string, componentId?: string | number): Promise { + getPackageExtra(siteId: string, component: string, componentId?: string | number): Promise { return this.getPackageData(siteId, component, componentId).then((entry) => entry.extra); } @@ -1842,7 +1957,7 @@ export class CoreFilepoolProvider { * @param url URL to get the args. * @return The args found, undefined if not a pluginfile. */ - protected getPluginFileArgs(url: string): string[] { + protected getPluginFileArgs(url: string): string[] | undefined { if (!CoreUrlUtils.instance.isPluginFileUrl(url)) { // Not pluginfile, return. return; @@ -1868,8 +1983,12 @@ export class CoreFilepoolProvider { * @param onProgress Function to call on progress. * @return Deferred. */ - protected getQueueDeferred(siteId: string, fileId: string, create: boolean = true, onProgress?: CoreFilepoolOnProgressCallback): - CoreFilepoolPromiseDefer { + protected getQueueDeferred( + siteId: string, + fileId: string, + create: boolean = true, + onProgress?: CoreFilepoolOnProgressCallback, + ): CoreFilepoolPromiseDefer | undefined { if (!this.queueDeferreds[siteId]) { if (!create) { return; @@ -1897,11 +2016,10 @@ export class CoreFilepoolProvider { * @param fileId The file ID. * @return On progress function, undefined if not found. */ - protected getQueueOnProgress(siteId: string, fileId: string): CoreFilepoolOnProgressCallback { + protected getQueueOnProgress(siteId: string, fileId: string): CoreFilepoolOnProgressCallback | undefined { const deferred = this.getQueueDeferred(siteId, fileId, false); - if (deferred) { - return deferred.onProgress; - } + + return deferred?.onProgress; } /** @@ -1913,9 +2031,15 @@ export class CoreFilepoolProvider { * @param onProgress Function to call on progress. * @return Promise. */ - protected getQueuePromise(siteId: string, fileId: string, create: boolean = true, onProgress?: CoreFilepoolOnProgressCallback): - Promise { - return this.getQueueDeferred(siteId, fileId, create, onProgress).promise; + protected getQueuePromise( + siteId: string, + fileId: string, + create: boolean = true, + onProgress?: CoreFilepoolOnProgressCallback, + ): Promise | undefined { + const deferred = this.getQueueDeferred(siteId, fileId, create, onProgress); + + return deferred?.promise; } /** @@ -1984,11 +2108,29 @@ export class CoreFilepoolProvider { * This will return a URL pointing to the content of the requested URL. * The URL returned is compatible to use with IMG tags. */ - getSrcByUrl(siteId: string, fileUrl: string, component: string, componentId?: string | number, timemodified: number = 0, - checkSize: boolean = true, downloadUnknown?: boolean, options: CoreFilepoolFileOptions = {}, revision?: number): - Promise { - return this.getFileUrlByUrl(siteId, fileUrl, component, componentId, 'src', - timemodified, checkSize, downloadUnknown, options, revision); + getSrcByUrl( + siteId: string, + fileUrl: string, + component?: string, + componentId?: string | number, + timemodified: number = 0, + checkSize: boolean = true, + downloadUnknown?: boolean, + options: CoreFilepoolFileOptions = {}, + revision?: number, + ): Promise { + return this.getFileUrlByUrl( + siteId, + fileUrl, + component, + componentId, + 'src', + timemodified, + checkSize, + downloadUnknown, + options, + revision, + ); } /** @@ -2001,7 +2143,7 @@ export class CoreFilepoolProvider { let timemodified = 0; files.forEach((file) => { - if (file.timemodified > timemodified) { + if (file.timemodified && file.timemodified > timemodified) { timemodified = file.timemodified; } }); @@ -2028,11 +2170,29 @@ export class CoreFilepoolProvider { * This will return a URL pointing to the content of the requested URL. * The URL returned is compatible to use with a local browser. */ - getUrlByUrl(siteId: string, fileUrl: string, component: string, componentId?: string | number, timemodified: number = 0, - checkSize: boolean = true, downloadUnknown?: boolean, options: CoreFilepoolFileOptions = {}, revision?: number): - Promise { - return this.getFileUrlByUrl(siteId, fileUrl, component, componentId, 'url', - timemodified, checkSize, downloadUnknown, options, revision); + getUrlByUrl( + siteId: string, + fileUrl: string, + component?: string, + componentId?: string | number, + timemodified: number = 0, + checkSize: boolean = true, + downloadUnknown?: boolean, + options: CoreFilepoolFileOptions = {}, + revision?: number, + ): Promise { + return this.getFileUrlByUrl( + siteId, + fileUrl, + component, + componentId, + 'url', + timemodified, + checkSize, + downloadUnknown, + options, + revision, + ); } /** @@ -2071,7 +2231,7 @@ export class CoreFilepoolProvider { // If there are hashes in the URL, extract them. const index = filename.indexOf('#'); - let hashes: string[]; + let hashes: string[] | undefined; if (index != -1) { hashes = filename.split('#'); @@ -2127,7 +2287,7 @@ export class CoreFilepoolProvider { throw new CoreError('File not found in queue.'); } // Convert the links to an object. - entry.linksUnserialized = CoreTextUtils.instance.parseJSON(entry.links, []); + entry.linksUnserialized = CoreTextUtils.instance.parseJSON(entry.links || '[]', []); return entry; } @@ -2143,7 +2303,7 @@ export class CoreFilepoolProvider { async invalidateAllFiles(siteId: string, onlyUnknown: boolean = true): Promise { const db = await CoreSites.instance.getSiteDb(siteId); - const where = onlyUnknown ? CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE : null; + const where = onlyUnknown ? CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE : undefined; await db.updateRecordsWhere(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, where); } @@ -2179,8 +2339,12 @@ export class CoreFilepoolProvider { * It is advised to set it to true to reduce the performance and data usage of the app. * @return Resolved when done. */ - async invalidateFilesByComponent(siteId: string, component: string, componentId?: string | number, onlyUnknown: boolean = true): - Promise { + async invalidateFilesByComponent( + siteId: string, + component: string, + componentId?: string | number, + onlyUnknown: boolean = true, + ): Promise { const db = await CoreSites.instance.getSiteDb(siteId); const items = await this.getComponentFiles(db, component, componentId); @@ -2223,8 +2387,13 @@ export class CoreFilepoolProvider { * @param revision File revision. If not defined, it will be calculated using the URL. * @return Promise resolved with a boolean: whether a file is downloadable. */ - async isFileDownloadable(siteId: string, fileUrl: string, timemodified: number = 0, filePath?: string, revision?: number): - Promise { + async isFileDownloadable( + siteId: string, + fileUrl: string, + timemodified: number = 0, + filePath?: string, + revision?: number, + ): Promise { const state = await this.getFileStateByUrl(siteId, fileUrl, timemodified, filePath, revision); return state != CoreConstants.NOT_DOWNLOADABLE; @@ -2253,7 +2422,9 @@ export class CoreFilepoolProvider { * @param Whether the file is outdated. */ protected isFileOutdated(entry: CoreFilepoolFileEntry, revision?: number, timemodified?: number): boolean { - return !!entry.stale || revision > entry.revision || timemodified > entry.timemodified; + return !!entry.stale || + (revision !== undefined && (entry.revision === undefined || revision > entry.revision)) || + (timemodified !== undefined && (entry.timemodified === undefined || timemodified > entry.timemodified)); } /** @@ -2273,8 +2444,11 @@ export class CoreFilepoolProvider { * @param eventData The file event data. * @param links The links to the components. */ - protected notifyFileActionToComponents(siteId: string, eventData: CoreFilepoolFileEventData, - links: CoreFilepoolComponentLink[]): void { + protected notifyFileActionToComponents( + siteId: string, + eventData: CoreFilepoolFileEventData, + links: CoreFilepoolComponentLink[], + ): void { links.forEach((link) => { const data: CoreFilepoolComponentFileEventData = Object.assign({ component: link.component, @@ -2385,8 +2559,15 @@ export class CoreFilepoolProvider { * @param onProgress Function to call on progress. * @return Promise resolved when all files are downloaded. */ - prefetchPackage(siteId: string, fileList: CoreWSExternalFile[], component: string, componentId?: string | number, - extra?: string, dirPath?: string, onProgress?: CoreFilepoolOnProgressCallback): Promise { + prefetchPackage( + siteId: string, + fileList: CoreWSExternalFile[], + component: string, + componentId?: string | number, + extra?: string, + dirPath?: string, + onProgress?: CoreFilepoolOnProgressCallback, + ): Promise { return this.downloadOrPrefetchPackage(siteId, fileList, true, component, componentId, extra, dirPath, onProgress); } @@ -2397,24 +2578,17 @@ export class CoreFilepoolProvider { * This loops over itself to keep on processing the queue in the background. * The queue process is site agnostic. */ - protected processQueue(): void { - let promise: Promise; + protected async processQueue(): Promise { + try { + if (this.queueState !== CoreFilepoolProvider.QUEUE_RUNNING) { + // Silently ignore, the queue is on pause. + throw CoreFilepoolProvider.ERR_QUEUE_ON_PAUSE; + } else if (!CoreFile.instance.isAvailable() || !CoreApp.instance.isOnline()) { + throw CoreFilepoolProvider.ERR_FS_OR_NETWORK_UNAVAILABLE; + } - if (this.queueState !== CoreFilepoolProvider.QUEUE_RUNNING) { - // Silently ignore, the queue is on pause. - promise = Promise.reject(CoreFilepoolProvider.ERR_QUEUE_ON_PAUSE); - } else if (!CoreFile.instance.isAvailable() || !CoreApp.instance.isOnline()) { - promise = Promise.reject(CoreFilepoolProvider.ERR_FS_OR_NETWORK_UNAVAILABLE); - } else { - promise = this.processImportantQueueItem(); - } - - promise.then(() => { - // All good, we schedule next execution. - setTimeout(() => { - this.processQueue(); - }, CoreFilepoolProvider.QUEUE_PROCESS_INTERVAL); - }, (error) => { + await this.processImportantQueueItem(); + } catch (error) { // We had an error, in which case we pause the processing. if (error === CoreFilepoolProvider.ERR_FS_OR_NETWORK_UNAVAILABLE) { this.logger.debug('Filesysem or network unavailable, pausing queue processing.'); @@ -2423,7 +2597,14 @@ export class CoreFilepoolProvider { } this.queueState = CoreFilepoolProvider.QUEUE_PAUSED; - }); + + return; + } + + // All good, we schedule next execution. + setTimeout(() => { + this.processQueue(); + }, CoreFilepoolProvider.QUEUE_PROCESS_INTERVAL); } /** @@ -2437,8 +2618,14 @@ export class CoreFilepoolProvider { let items: CoreFilepoolQueueEntry[]; try { - items = await this.appDB.getRecords(CoreFilepoolProvider.QUEUE_TABLE, undefined, - 'priority DESC, added ASC', undefined, 0, 1); + items = await this.appDB.getRecords( + CoreFilepoolProvider.QUEUE_TABLE, + undefined, + 'priority DESC, added ASC', + undefined, + 0, + 1, + ); } catch (err) { throw CoreFilepoolProvider.ERR_QUEUE_IS_EMPTY; } @@ -2475,7 +2662,7 @@ export class CoreFilepoolProvider { this.logger.debug('Processing queue item: ' + siteId + ', ' + fileId); - let entry: CoreFilepoolFileEntry; + let entry: CoreFilepoolFileEntry | undefined; // Check if the file is already in pool. try { @@ -2502,21 +2689,19 @@ export class CoreFilepoolProvider { // The file does not exist, or is stale, ... download it. const onProgress = this.getQueueOnProgress(siteId, fileId); - return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, entry).then(() => { + try { + await this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, entry); + // Success, we add links and remove from queue. - this.addFileLinks(siteId, fileId, links).catch(() => { - // Ignore errors. - }); + CoreUtils.instance.ignoreErrors(this.addFileLinks(siteId, fileId, links)); this.treatQueueDeferred(siteId, fileId, true); this.notifyFileDownloaded(siteId, fileId, links); // Wait for the item to be removed from queue before resolving the promise. // If the item could not be removed from queue we still resolve the promise. - return this.removeFromQueue(siteId, fileId).catch(() => { - // Ignore errors. - }); - }, (errorObject) => { + await CoreUtils.instance.ignoreErrors(this.removeFromQueue(siteId, fileId)); + } catch (errorObject) { // Whoops, we have an error... let dropFromQueue = false; @@ -2544,7 +2729,7 @@ export class CoreFilepoolProvider { dropFromQueue = true; } - let errorMessage = null; + let errorMessage: string | undefined; // Some Android devices restrict the amount of usable storage using quotas. // If this quota would be exceeded by the download, it throws an exception. // We catch this exception here, and report a meaningful error message to the user. @@ -2555,20 +2740,18 @@ export class CoreFilepoolProvider { if (dropFromQueue) { this.logger.debug('Item dropped from queue due to error: ' + fileUrl, errorObject); - return this.removeFromQueue(siteId, fileId).catch(() => { - // Consider this as a silent error, never reject the promise here. - }).then(() => { - this.treatQueueDeferred(siteId, fileId, false, errorMessage); - this.notifyFileDownloadError(siteId, fileId, links); - }); + await CoreUtils.instance.ignoreErrors(this.removeFromQueue(siteId, fileId)); + + this.treatQueueDeferred(siteId, fileId, false, errorMessage); + this.notifyFileDownloadError(siteId, fileId, links); } else { // We considered the file as legit but did not get it, failure. this.treatQueueDeferred(siteId, fileId, false, errorMessage); this.notifyFileDownloadError(siteId, fileId, links); - return Promise.reject(errorObject); + throw errorObject; } - }); + } } /** @@ -2596,12 +2779,12 @@ export class CoreFilepoolProvider { // Get the path to the file first since it relies on the file object stored in the pool. // Don't use getFilePath to prevent performing 2 DB requests. let path = this.getFilepoolFolderPath(siteId) + '/' + fileId; - let fileUrl: string; + let fileUrl: string | undefined; try { const entry = await this.hasFileInPool(siteId, fileId); - fileUrl = entry.url; + fileUrl = entry.url; if (entry.extension) { path += '.' + entry.extension; } @@ -2615,7 +2798,7 @@ export class CoreFilepoolProvider { // Get links to components to notify them after remove. const links = await this.getFileLinks(siteId, fileId); - const promises = []; + const promises: Promise[] = []; // Remove entry from filepool store. promises.push(db.deleteRecords(CoreFilepoolProvider.FILES_TABLE, conditions)); @@ -2629,7 +2812,7 @@ export class CoreFilepoolProvider { if (error && error.code == 1) { // Not found, ignore error since maybe it was deleted already. } else { - return Promise.reject(error); + throw error; } })); } @@ -2638,10 +2821,8 @@ export class CoreFilepoolProvider { this.notifyFileDeleted(siteId, fileId, links); - try { - await CorePluginFile.instance.fileDeleted(fileUrl, path, siteId); - } catch (error) { - // Ignore errors. + if (fileUrl) { + await CoreUtils.instance.ignoreErrors(CorePluginFile.instance.fileDeleted(fileUrl, path, siteId)); } } @@ -2720,7 +2901,7 @@ export class CoreFilepoolProvider { await site.getDb().updateRecords(CoreFilepoolProvider.PACKAGES_TABLE, newData, { id: packageId }); // Success updating, trigger event. - this.triggerPackageStatusChanged(site.id, newData.status, component, componentId); + this.triggerPackageStatusChanged(site.id!, newData.status, component, componentId); return newData.status; } @@ -2778,22 +2959,27 @@ export class CoreFilepoolProvider { * @param extra Extra data to store for the package. If you want to store more than 1 value, use JSON.stringify. * @return Promise resolved when status is stored. */ - async storePackageStatus(siteId: string, status: string, component: string, componentId?: string | number, extra?: string): - Promise { + async storePackageStatus( + siteId: string, + status: string, + component: string, + componentId?: string | number, + extra?: string, + ): Promise { this.logger.debug(`Set status '${status}' for package ${component} ${componentId}`); componentId = this.fixComponentId(componentId); const site = await CoreSites.instance.getSite(siteId); const packageId = this.getPackageId(component, componentId); - let downloadTime: number; - let previousDownloadTime: number; + let downloadTime: number | undefined; + let previousDownloadTime: number | undefined; if (status == CoreConstants.DOWNLOADING) { // Set download time if package is now downloading. downloadTime = CoreTimeUtils.instance.timestamp(); } - let previousStatus: string; + let previousStatus: string | undefined; // Search current status to set it as previous status. try { const entry = site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); @@ -2849,40 +3035,56 @@ export class CoreFilepoolProvider { * @param revision Revision to use in all files. If not defined, it will be calculated using the URL of each file. * @return Promise resolved with the CSS code. */ - treatCSSCode(siteId: string, fileUrl: string, cssCode: string, component?: string, componentId?: string | number, - revision?: number): Promise { + async treatCSSCode( + siteId: string, + fileUrl: string, + cssCode: string, + component?: string, + componentId?: string | number, + revision?: number, + ): Promise { const urls = CoreDomUtils.instance.extractUrlsFromCSS(cssCode); - const promises = []; - let filePath: string; let updated = false; // Get the path of the CSS file. - promises.push(this.getFilePathByUrl(siteId, fileUrl).then((path) => { - filePath = path; + const filePath = await this.getFilePathByUrl(siteId, fileUrl); + + // Download all files in the CSS. + await Promise.all(urls.map(async (url) => { + // Download the file only if it's an online URL. + if (CoreUrlUtils.instance.isLocalFileUrl(url)) { + return; + } + + try { + const fileUrl = await this.downloadUrl( + siteId, + url, + false, + component, + componentId, + 0, + undefined, + undefined, + undefined, + revision, + ); + + if (fileUrl != url) { + cssCode = cssCode.replace(new RegExp(CoreTextUtils.instance.escapeForRegex(url), 'g'), fileUrl); + updated = true; + } + } catch (error) { + this.logger.warn('Error treating file ', url, error); + } })); - urls.forEach((url) => { - // Download the file only if it's an online URL. - if (!CoreUrlUtils.instance.isLocalFileUrl(url)) { - promises.push(this.downloadUrl(siteId, url, false, component, componentId, 0, undefined, undefined, undefined, - revision).then((fileUrl) => { - if (fileUrl != url) { - cssCode = cssCode.replace(new RegExp(CoreTextUtils.instance.escapeForRegex(url), 'g'), fileUrl); - updated = true; - } - }).catch((error) => { - // It shouldn't happen. Ignore errors. - this.logger.warn('Error treating file ', url, error); - })); - } - }); + // All files downloaded. Store the result if it has changed. + if (updated) { + await CoreFile.instance.writeFile(filePath, cssCode); + } - return Promise.all(promises).then(() => { - // All files downloaded. Store the result if it has changed. - if (updated) { - return CoreFile.instance.writeFile(filePath, cssCode); - } - }).then(() => cssCode); + return cssCode; } /** @@ -2912,7 +3114,7 @@ export class CoreFilepoolProvider { * @param component Package's component. * @param componentId An ID to use in conjunction with the component. */ - protected triggerPackageStatusChanged(siteId: string, status: string, component: string, componentId?: string | number): void { + protected triggerPackageStatusChanged(siteId: string, status: string, component?: string, componentId?: string | number): void { const data = { component, componentId: this.fixComponentId(componentId), @@ -2938,8 +3140,11 @@ export class CoreFilepoolProvider { const site = await CoreSites.instance.getSite(siteId); const packageId = this.getPackageId(component, componentId); - await site.getDb().updateRecords(CoreFilepoolProvider.PACKAGES_TABLE, - { downloadTime: CoreTimeUtils.instance.timestamp() }, { id: packageId }); + await site.getDb().updateRecords( + CoreFilepoolProvider.PACKAGES_TABLE, + { downloadTime: CoreTimeUtils.instance.timestamp() }, + { id: packageId }, + ); } } @@ -2963,32 +3168,32 @@ export type CoreFilepoolFileEntry = CoreFilepoolFileOptions & { /** * The fileId to identify the file. */ - fileId?: string; + fileId: string; /** * File's URL. */ - url?: string; + url: string; /** * 1 if file is stale (needs to be updated), 0 otherwise. */ - stale?: number; + stale: number; /** * Timestamp when this file was downloaded. */ - downloadTime?: number; + downloadTime: number; /** * File's path. */ - path?: string; + path: string; /** * File's extension. */ - extension?: string; + extension: string; }; /** @@ -2998,27 +3203,27 @@ export type CoreFilepoolQueueDBEntry = CoreFilepoolFileOptions & { /** * The site the file belongs to. */ - siteId?: string; + siteId: string; /** * The fileId to identify the file. */ - fileId?: string; + fileId: string; /** * Timestamp when the file was added to the queue. */ - added?: number; + added: number; /** * The priority of the file. */ - priority?: number; + priority: number; /** * File's URL. */ - url?: string; + url: string; /** * File's path. @@ -3028,7 +3233,7 @@ export type CoreFilepoolQueueDBEntry = CoreFilepoolFileOptions & { /** * File links (to link the file to components and componentIds). Serialized to store on DB. */ - links?: string; + links: string; }; /** @@ -3148,7 +3353,7 @@ export type CoreFilepoolComponentFileEventData = CoreFilepoolFileEventData & { /** * The component ID. */ - componentId: string | number; + componentId?: string | number; }; /** @@ -3171,3 +3376,6 @@ type CoreFilepoolLinksRecord = { component: string; // Component name. componentId: number | string; // Component Id. }; + +type AnchorOrMediaElement = + HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | HTMLTrackElement; diff --git a/src/app/services/local-notifications.ts b/src/app/services/local-notifications.ts index 385837a89..9c2175d44 100644 --- a/src/app/services/local-notifications.ts +++ b/src/app/services/local-notifications.ts @@ -106,7 +106,7 @@ export class CoreLocalNotificationsProvider { 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). + protected queueRunner: CoreQueueRunner; // Queue to decrease the number of concurrent calls to the plugin (see MOBILE-3477). constructor() { this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider'); @@ -116,46 +116,53 @@ export class CoreLocalNotificationsProvider { // Ignore errors. }); - Platform.instance.ready().then(() => { - // Listen to events. - this.triggerSubscription = LocalNotifications.instance.on('trigger').subscribe((notification: ILocalNotification) => { - this.trigger(notification); + this.init(); + } - this.handleEvent('trigger', notification); - }); + /** + * Init some properties. + */ + protected async init(): Promise { + await Platform.instance.ready(); - this.clickSubscription = LocalNotifications.instance.on('click').subscribe((notification: ILocalNotification) => { - this.handleEvent('click', notification); - }); + // Listen to events. + this.triggerSubscription = LocalNotifications.instance.on('trigger').subscribe((notification: ILocalNotification) => { + this.trigger(notification); - this.clearSubscription = LocalNotifications.instance.on('clear').subscribe((notification: ILocalNotification) => { - this.handleEvent('clear', notification); - }); + this.handleEvent('trigger', notification); + }); - this.cancelSubscription = LocalNotifications.instance.on('cancel').subscribe((notification: ILocalNotification) => { - this.handleEvent('cancel', notification); - }); + this.clickSubscription = LocalNotifications.instance.on('click').subscribe((notification: ILocalNotification) => { + this.handleEvent('click', notification); + }); - this.addSubscription = LocalNotifications.instance.on('schedule').subscribe((notification: ILocalNotification) => { - this.handleEvent('schedule', notification); - }); + this.clearSubscription = LocalNotifications.instance.on('clear').subscribe((notification: ILocalNotification) => { + this.handleEvent('clear', notification); + }); - this.updateSubscription = LocalNotifications.instance.on('update').subscribe((notification: ILocalNotification) => { - this.handleEvent('update', notification); - }); + this.cancelSubscription = LocalNotifications.instance.on('cancel').subscribe((notification: ILocalNotification) => { + this.handleEvent('cancel', notification); + }); - // Create the default channel for local notifications. + 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(() => { + // Update the channel name. this.createDefaultChannel(); - - Translate.instance.onLangChange.subscribe(() => { - // Update the channel name. - this.createDefaultChannel(); - }); }); CoreEvents.instance.on(CoreEventsProvider.SITE_DELETED, (site: CoreSite) => { if (site) { - this.cancelSiteNotifications(site.id); + this.cancelSiteNotifications(site.id!); } }); } @@ -193,13 +200,13 @@ export class CoreLocalNotificationsProvider { const scheduled = await this.getAllScheduled(); - const ids = []; + const ids: number[] = []; const queueId = 'cancelSiteNotifications-' + siteId; scheduled.forEach((notif) => { notif.data = this.parseNotificationData(notif.data); - if (typeof notif.data == 'object' && notif.data.siteId === siteId) { + if (notif.id && typeof notif.data == 'object' && notif.data.siteId === siteId) { ids.push(notif.id); } }); @@ -355,10 +362,9 @@ export class CoreLocalNotificationsProvider { * @return Whether local notifications plugin is installed. */ isAvailable(): boolean { - const win = window; + const win = window; // eslint-disable-line @typescript-eslint/no-explicit-any - return CoreApp.instance.isDesktop() || !!(win.cordova && win.cordova.plugins && win.cordova.plugins.notification && - win.cordova.plugins.notification.local); + return CoreApp.instance.isDesktop() || !!win.cordova?.plugins?.notification?.local; } /** @@ -388,11 +394,11 @@ export class CoreLocalNotificationsProvider { if (useQueue) { const queueId = 'isTriggered-' + notification.id; - return this.queueRunner.run(queueId, () => LocalNotifications.instance.isTriggered(notification.id), { + return this.queueRunner.run(queueId, () => LocalNotifications.instance.isTriggered(notification.id!), { allowRepeated: true, }); } else { - return LocalNotifications.instance.isTriggered(notification.id); + return LocalNotifications.instance.isTriggered(notification.id || 0); } } } @@ -446,9 +452,8 @@ export class CoreLocalNotificationsProvider { /** * Process the next request in queue. */ - protected processNextRequest(): void { + protected async processNextRequest(): Promise { const nextKey = Object.keys(this.codeRequestsQueue)[0]; - let promise: Promise; if (typeof nextKey == 'undefined') { // No more requests in queue, stop. @@ -457,27 +462,27 @@ export class CoreLocalNotificationsProvider { const 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.deferreds.forEach((p) => { - p.resolve(code); - }); - }).catch((error) => { - request.deferreds.forEach((p) => { - p.reject(error); - }); - }); - } else { - promise = Promise.resolve(); - } + try { + // Check if request is valid. + if (typeof request != 'object' || request.table === undefined || request.id === undefined) { + return; + } - // Once this item is treated, remove it and process next. - promise.finally(() => { + // Get the code and resolve/reject all the promises of this request. + const code = await this.getCode(request.table, request.id); + + request.deferreds.forEach((p) => { + p.resolve(code); + }); + } catch (error) { + request.deferreds.forEach((p) => { + p.reject(error); + }); + } finally { + // Once this item is treated, remove it and process next. delete this.codeRequestsQueue[nextKey]; this.processNextRequest(); - }); + } } /** @@ -596,7 +601,7 @@ export class CoreLocalNotificationsProvider { */ async schedule(notification: ILocalNotification, component: string, siteId: string, alreadyUnique?: boolean): Promise { if (!alreadyUnique) { - notification.id = await this.getUniqueNotificationId(notification.id, component, siteId); + notification.id = await this.getUniqueNotificationId(notification.id || 0, component, siteId); } notification.data = notification.data || {}; @@ -663,7 +668,7 @@ export class CoreLocalNotificationsProvider { } if (!soundEnabled) { - notification.sound = null; + notification.sound = undefined; } else { delete notification.sound; // Use default value. } @@ -671,7 +676,7 @@ export class CoreLocalNotificationsProvider { notification.foreground = true; // Remove from triggered, since the notification could be in there with a different time. - this.removeTriggered(notification.id); + this.removeTriggered(notification.id || 0); LocalNotifications.instance.schedule(notification); } }); diff --git a/src/app/services/plugin-file-delegate.ts b/src/app/services/plugin-file-delegate.ts index 8621d13b8..572cc0913 100644 --- a/src/app/services/plugin-file-delegate.ts +++ b/src/app/services/plugin-file-delegate.ts @@ -206,7 +206,7 @@ export class CorePluginFileDelegate extends CoreDelegate { } } - return downloadableFile.filesize; + return downloadableFile.filesize || 0; } /** @@ -215,7 +215,7 @@ export class CorePluginFileDelegate extends CoreDelegate { * @param file File data. * @return Handler. */ - protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler { + protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler | undefined { for (const component in this.enabledHandlers) { const handler = this.enabledHandlers[component]; diff --git a/src/app/services/sites.ts b/src/app/services/sites.ts index 004be6ac3..923803668 100644 --- a/src/app/services/sites.ts +++ b/src/app/services/sites.ts @@ -130,7 +130,7 @@ export class CoreSitesProvider { // Move the records from the old table. const sites = await db.getAllRecords(oldTable); - const promises = []; + const promises: Promise[] = []; sites.forEach((site) => { promises.push(db.insertRecord(newTable, site)); @@ -153,12 +153,12 @@ export class CoreSitesProvider { protected readonly VALID_VERSION = 1; protected readonly INVALID_VERSION = -1; - protected isWPApp: boolean; + protected isWPApp = false; protected logger: CoreLogger; protected services = {}; protected sessionRestored = false; - protected currentSite: CoreSite; + protected currentSite?: CoreSite; protected sites: { [s: string]: CoreSite } = {}; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. @@ -249,7 +249,8 @@ export class CoreSitesProvider { await db.execute( 'INSERT INTO ' + newTable + ' ' + 'SELECT id, data, key, expirationTime, NULL as component, NULL as componentId ' + - 'FROM ' + oldTable); + 'FROM ' + oldTable, + ); try { await db.dropTable(oldTable); @@ -276,7 +277,7 @@ export class CoreSitesProvider { * @param name Name of the site to check. * @return Site data if it's a demo site, undefined otherwise. */ - getDemoSiteData(name: string): CoreSitesDemoSiteData { + getDemoSiteData(name: string): CoreSitesDemoSiteData | undefined { const demoSites = CoreConfigConstants.demo_sites; name = name.toLowerCase(); @@ -293,39 +294,43 @@ export class CoreSitesProvider { * @param protocol Protocol to use first. * @return A promise resolved when the site is checked. */ - checkSite(siteUrl: string, protocol: string = 'https://'): Promise { + async checkSite(siteUrl: string, protocol: string = 'https://'): Promise { // The formatURL function adds the protocol if is missing. siteUrl = CoreUrlUtils.instance.formatURL(siteUrl); if (!CoreUrlUtils.instance.isHttpURL(siteUrl)) { - return Promise.reject(new CoreError(Translate.instance.instant('core.login.invalidsite'))); + throw new CoreError(Translate.instance.instant('core.login.invalidsite')); } else if (!CoreApp.instance.isOnline()) { - return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); - } else { - return this.checkSiteWithProtocol(siteUrl, protocol).catch((error: CoreSiteError) => { - // Do not continue checking if a critical error happened. - if (error.critical) { - return Promise.reject(error); + throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + } + + try { + return await this.checkSiteWithProtocol(siteUrl, protocol); + } catch (error) { + // Do not continue checking if a critical error happened. + if (error.critical) { + throw error; + } + + // Retry with the other protocol. + protocol = protocol == 'https://' ? 'http://' : 'https://'; + + try { + return await this.checkSiteWithProtocol(siteUrl, protocol); + } catch (secondError) { + if (secondError.critical) { + throw secondError; } - // Retry with the other protocol. - protocol = protocol == 'https://' ? 'http://' : 'https://'; - - return this.checkSiteWithProtocol(siteUrl, protocol).catch((secondError: CoreSiteError) => { - if (secondError.critical) { - return Promise.reject(secondError); - } - - // Site doesn't exist. Return the error message. - if (CoreTextUtils.instance.getErrorMessageFromError(error)) { - return Promise.reject(error); - } else if (CoreTextUtils.instance.getErrorMessageFromError(secondError)) { - return Promise.reject(secondError); - } else { - return Translate.instance.instant('core.cannotconnecttrouble'); - } - }); - }); + // Site doesn't exist. Return the error message. + if (CoreTextUtils.instance.getErrorMessageFromError(error)) { + throw error; + } else if (CoreTextUtils.instance.getErrorMessageFromError(secondError)) { + throw secondError; + } else { + throw new CoreError(Translate.instance.instant('core.cannotconnecttrouble')); + } + } } } @@ -336,121 +341,123 @@ export class CoreSitesProvider { * @param protocol Protocol to use. * @return A promise resolved when the site is checked. */ - checkSiteWithProtocol(siteUrl: string, protocol: string): Promise { - let publicConfig: CoreSitePublicConfigResponse; + async checkSiteWithProtocol(siteUrl: string, protocol: string): Promise { + let publicConfig: CoreSitePublicConfigResponse | undefined; // Now, replace the siteUrl with the protocol. siteUrl = siteUrl.replace(/^https?:\/\//i, protocol); - return this.siteExists(siteUrl).catch((error: CoreSiteError) => { + try { + await this.siteExists(siteUrl); + } catch (error) { // Do not continue checking if WS are not enabled. if (error.errorcode == 'enablewsdescription') { error.critical = true; - return Promise.reject(error); + throw error; } // Site doesn't exist. Try to add or remove 'www'. const treatedUrl = CoreUrlUtils.instance.addOrRemoveWWW(siteUrl); - return this.siteExists(treatedUrl).then(() => { + try { + await this.siteExists(treatedUrl); + // Success, use this new URL as site url. siteUrl = treatedUrl; - }).catch((secondError: CoreSiteError) => { + } catch (secondError) { // Do not continue checking if WS are not enabled. if (secondError.errorcode == 'enablewsdescription') { secondError.critical = true; - return Promise.reject(secondError); + throw secondError; } // Return the error. if (CoreTextUtils.instance.getErrorMessageFromError(error)) { - return Promise.reject(error); + throw error; } else { - return Promise.reject(secondError); + throw secondError; } + } + } + + // Site exists. Create a temporary site to check if local_mobile is installed. + const temporarySite = new CoreSite(undefined, siteUrl); + let data: LocalMobileResponse; + + try { + data = await temporarySite.checkLocalMobilePlugin(); + } catch (error) { + // Local mobile check returned an error. This only happens if the plugin is installed and it returns an error. + throw new CoreSiteError({ + message: error.message, + critical: true, }); - }).then(() => { - // Create a temporary site to check if local_mobile is installed. - const temporarySite = new CoreSite(undefined, siteUrl); + } - return temporarySite.checkLocalMobilePlugin().then((data) => { - data.service = data.service || CoreConfigConstants.wsservice; - this.services[siteUrl] = data.service; // No need to store it in DB. + data.service = data.service || CoreConfigConstants.wsservice; + this.services[siteUrl] = data.service; // No need to store it in DB. - if (data.coreSupported || - (data.code != CoreConstants.LOGIN_SSO_CODE && data.code != CoreConstants.LOGIN_SSO_INAPP_CODE)) { - // SSO using local_mobile not needed, try to get the site public config. - return temporarySite.getPublicConfig().then((config) => { - publicConfig = config; + if (data.coreSupported || (data.code != CoreConstants.LOGIN_SSO_CODE && data.code != CoreConstants.LOGIN_SSO_INAPP_CODE)) { + // SSO using local_mobile not needed, try to get the site public config. + try { + const config = await temporarySite.getPublicConfig(); - // Check that the user can authenticate. - if (!config.enablewebservices) { - return Promise.reject(new CoreSiteError({ - message: Translate.instance.instant('core.login.webservicesnotenabled'), - })); - } else if (!config.enablemobilewebservice) { - return Promise.reject(new CoreSiteError({ - message: Translate.instance.instant('core.login.mobileservicesnotenabled'), - })); - } else if (config.maintenanceenabled) { - let message = Translate.instance.instant('core.sitemaintenance'); - if (config.maintenancemessage) { - message += config.maintenancemessage; - } + publicConfig = config; - return Promise.reject(new CoreSiteError({ - message, - })); - } + // Check that the user can authenticate. + if (!config.enablewebservices) { + throw new CoreSiteError({ + message: Translate.instance.instant('core.login.webservicesnotenabled'), + }); + } else if (!config.enablemobilewebservice) { + throw new CoreSiteError({ + message: Translate.instance.instant('core.login.mobileservicesnotenabled'), + }); + } else if (config.maintenanceenabled) { + let message = Translate.instance.instant('core.sitemaintenance'); + if (config.maintenancemessage) { + message += config.maintenancemessage; + } - // Everything ok. - if (data.code === 0) { - data.code = config.typeoflogin; - } - - return data; - }, async (error) => { - // Error, check if not supported. - if (error.available === 1) { - // Service supported but an error happened. Return error. - if (error.errorcode == 'codingerror') { - // This could be caused by a redirect. Check if it's the case. - const redirect = await CoreUtils.instance.checkRedirect(siteUrl); - - if (redirect) { - error.error = Translate.instance.instant('core.login.sitehasredirect'); - } else { - // We can't be sure if there is a redirect or not. Display cannot connect error. - error.error = Translate.instance.instant('core.cannotconnecttrouble'); - } - } - - return Promise.reject(new CoreSiteError({ - message: error.error, - errorcode: error.errorcode, - critical: true, - })); - } - - return data; + throw new CoreSiteError({ + message, }); } - return data; - }, (error: CoreError) => - // Local mobile check returned an error. This only happens if the plugin is installed and it returns an error. - Promise.reject(new CoreSiteError({ - message: error.message, - critical: true, - })), - ).then((data: LocalMobileResponse) => { - siteUrl = temporarySite.getURL(); + // Everything ok. + if (data.code === 0) { + data.code = config.typeoflogin; + } + } catch (error) { + // Error, check if not supported. + if (error.available === 1) { + // Service supported but an error happened. Return error. + if (error.errorcode == 'codingerror') { + // This could be caused by a redirect. Check if it's the case. + const redirect = await CoreUtils.instance.checkRedirect(siteUrl); - return { siteUrl, code: data.code, warning: data.warning, service: data.service, config: publicConfig }; - }); - }); + if (redirect) { + error.error = Translate.instance.instant('core.login.sitehasredirect'); + } else { + // We can't be sure if there is a redirect or not. Display cannot connect error. + error.error = Translate.instance.instant('core.cannotconnecttrouble'); + } + } + + throw new CoreSiteError({ + message: error.error, + errorcode: error.errorcode, + critical: true, + }); + } + } + } + + siteUrl = temporarySite.getURL(); + + return { siteUrl, code: data.code, warning: data.warning, service: data.service, config: publicConfig }; } /** @@ -482,7 +489,7 @@ export class CoreSitesProvider { if (data.errorcode && (data.errorcode == 'enablewsdescription' || data.errorcode == 'requirecorrectaccess')) { throw new CoreSiteError({ errorcode: data.errorcode, - message: data.error, + message: data.error!, }); } @@ -506,10 +513,15 @@ export class CoreSitesProvider { * @param retry Whether we are retrying with a prefixed URL. * @return A promise resolved when the token is retrieved. */ - getUserToken(siteUrl: string, username: string, password: string, service?: string, retry?: boolean): - Promise { + async getUserToken( + siteUrl: string, + username: string, + password: string, + service?: string, + retry?: boolean, + ): Promise { if (!CoreApp.instance.isOnline()) { - return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); + throw new CoreError(Translate.instance.instant('core.networkerrormsg')); } if (!service) { @@ -522,47 +534,46 @@ export class CoreSitesProvider { service, }; const loginUrl = siteUrl + '/login/token.php'; - const promise = Http.instance.post(loginUrl, params).pipe(timeout(CoreWS.instance.getRequestTimeout())).toPromise(); + let data: CoreSitesLoginTokenResponse; - return promise.then((data: CoreSitesLoginTokenResponse) => { - if (typeof data == 'undefined') { - return Promise.reject(new CoreError(Translate.instance.instant('core.cannotconnecttrouble'))); + try { + data = await Http.instance.post(loginUrl, params).pipe(timeout(CoreWS.instance.getRequestTimeout())).toPromise(); + } catch (error) { + throw new CoreError(Translate.instance.instant('core.cannotconnecttrouble')); + } + + if (typeof data == 'undefined') { + throw new CoreError(Translate.instance.instant('core.cannotconnecttrouble')); + } else { + if (typeof data.token != 'undefined') { + return { token: data.token, siteUrl, privateToken: data.privatetoken }; } else { - if (typeof data.token != 'undefined') { - return { token: data.token, siteUrl, privateToken: data.privatetoken }; - } else { - if (typeof data.error != 'undefined') { - // We only allow one retry (to avoid loops). - if (!retry && data.errorcode == 'requirecorrectaccess') { - siteUrl = CoreUrlUtils.instance.addOrRemoveWWW(siteUrl); + if (typeof data.error != 'undefined') { + // We only allow one retry (to avoid loops). + if (!retry && data.errorcode == 'requirecorrectaccess') { + siteUrl = CoreUrlUtils.instance.addOrRemoveWWW(siteUrl); - return this.getUserToken(siteUrl, username, password, service, true); - } else if (data.errorcode == 'missingparam') { - // It seems the server didn't receive all required params, it could be due to a redirect. - return CoreUtils.instance.checkRedirect(loginUrl).then((redirect) => { - if (redirect) { - return Promise.reject(new CoreSiteError({ - message: Translate.instance.instant('core.login.sitehasredirect'), - })); - } else { - return Promise.reject(new CoreSiteError({ - message: data.error, - errorcode: data.errorcode, - })); - } + return this.getUserToken(siteUrl, username, password, service, true); + } else if (data.errorcode == 'missingparam') { + // It seems the server didn't receive all required params, it could be due to a redirect. + const redirect = await CoreUtils.instance.checkRedirect(loginUrl); + + if (redirect) { + throw new CoreSiteError({ + message: Translate.instance.instant('core.login.sitehasredirect'), }); - } else { - return Promise.reject(new CoreSiteError({ - message: data.error, - errorcode: data.errorcode, - })); } - } else { - return Promise.reject(new CoreError(Translate.instance.instant('core.login.invalidaccount'))); } + + throw new CoreSiteError({ + message: data.error, + errorcode: data.errorcode, + }); } + + throw new CoreError(Translate.instance.instant('core.login.invalidaccount')); } - }, () => Promise.reject(new CoreError(Translate.instance.instant('core.cannotconnecttrouble')))); + } } /** @@ -575,7 +586,13 @@ export class CoreSitesProvider { * @param oauthId OAuth ID. Only if the authentication was using an OAuth method. * @return A promise resolved with siteId when the site is added and the user is authenticated. */ - newSite(siteUrl: string, token: string, privateToken: string = '', login: boolean = true, oauthId?: number): Promise { + async newSite( + siteUrl: string, + token: string, + privateToken: string = '', + login: boolean = true, + oauthId?: number, + ): Promise { if (typeof login != 'boolean') { login = true; } @@ -584,74 +601,77 @@ export class CoreSitesProvider { let candidateSite = new CoreSite(undefined, siteUrl, token, undefined, privateToken, undefined, undefined); let isNewSite = true; - return candidateSite.fetchSiteInfo().then((info) => { + try { + const info = await candidateSite.fetchSiteInfo(); + const result = this.isValidMoodleVersion(info); - if (result == this.VALID_VERSION) { - const siteId = this.createSiteID(info.siteurl, info.username); - - // Check if the site already exists. - return this.getSite(siteId).catch(() => { - // Not exists. - }).then((site) => { - if (site) { - // Site already exists, update its data and use it. - isNewSite = false; - candidateSite = site; - candidateSite.setToken(token); - candidateSite.setPrivateToken(privateToken); - candidateSite.setInfo(info); - candidateSite.setOAuthId(oauthId); - candidateSite.setLoggedOut(false); - } else { - // New site, set site ID and info. - isNewSite = true; - candidateSite.setId(siteId); - candidateSite.setInfo(info); - candidateSite.setOAuthId(oauthId); - - // Create database tables before login and before any WS call. - return this.migrateSiteSchemas(candidateSite); - } - }).then(() => - - // Try to get the site config. - this.getSiteConfig(candidateSite).catch((error) => { - // Ignore errors if it's not a new site, we'll use the config already stored. - if (isNewSite) { - return Promise.reject(error); - } - }).then((config) => { - if (typeof config != 'undefined') { - candidateSite.setConfig(config); - } - - // Add site to sites list. - this.addSite(siteId, siteUrl, token, info, privateToken, config, oauthId); - this.sites[siteId] = candidateSite; - - if (login) { - // Turn candidate site into current site. - this.currentSite = candidateSite; - // Store session. - this.login(siteId); - } - - CoreEvents.instance.trigger(CoreEventsProvider.SITE_ADDED, info, siteId); - - return siteId; - }), - ); + if (result != this.VALID_VERSION) { + return this.treatInvalidAppVersion(result, siteUrl); } - return this.treatInvalidAppVersion(result, siteUrl); - }).catch((error) => { + const siteId = this.createSiteID(info.siteurl, info.username); + + // Check if the site already exists. + const site = await CoreUtils.instance.ignoreErrors(this.getSite(siteId)); + + if (site) { + // Site already exists, update its data and use it. + isNewSite = false; + candidateSite = site; + candidateSite.setToken(token); + candidateSite.setPrivateToken(privateToken); + candidateSite.setInfo(info); + candidateSite.setOAuthId(oauthId); + candidateSite.setLoggedOut(false); + } else { + // New site, set site ID and info. + isNewSite = true; + candidateSite.setId(siteId); + candidateSite.setInfo(info); + candidateSite.setOAuthId(oauthId); + + // Create database tables before login and before any WS call. + await this.migrateSiteSchemas(candidateSite); + } + + // Try to get the site config. + let config: CoreSiteConfig | undefined; + + try { + config = await this.getSiteConfig(candidateSite); + } catch (error) { + // Ignore errors if it's not a new site, we'll use the config already stored. + if (isNewSite) { + throw error; + } + } + + if (typeof config != 'undefined') { + candidateSite.setConfig(config); + } + + // Add site to sites list. + this.addSite(siteId, siteUrl, token, info, privateToken, config, oauthId); + this.sites[siteId] = candidateSite; + + if (login) { + // Turn candidate site into current site. + this.currentSite = candidateSite; + // Store session. + this.login(siteId); + } + + CoreEvents.instance.trigger(CoreEventsProvider.SITE_ADDED, info, siteId); + + return siteId; + } catch (error) { // Error invaliddevice is returned by Workplace server meaning the same as connecttoworkplaceapp. if (error && error.errorcode == 'invaliddevice') { return this.treatInvalidAppVersion(this.WORKPLACE_APP, siteUrl); } - return Promise.reject(error); - }); + throw error; + } } /** @@ -663,8 +683,8 @@ export class CoreSitesProvider { * @return A promise rejected with the error info. */ protected async treatInvalidAppVersion(result: number, siteUrl: string, siteId?: string): Promise { - let errorCode; - let errorKey; + let errorCode: string | undefined; + let errorKey: string | undefined; let translateParams; switch (result) { @@ -816,8 +836,15 @@ export class CoreSitesProvider { * @param oauthId OAuth ID. Only if the authentication was using an OAuth method. * @return Promise resolved when done. */ - async addSite(id: string, siteUrl: string, token: string, info: CoreSiteInfoResponse, privateToken: string = '', - config?: CoreSiteConfig, oauthId?: number): Promise { + async addSite( + id: string, + siteUrl: string, + token: string, + info: CoreSiteInfoResponse, + privateToken: string = '', + config?: CoreSiteConfig, + oauthId?: number, + ): Promise { await this.dbReady; const entry = { @@ -850,47 +877,55 @@ export class CoreSitesProvider { * @param siteId ID of the site to check. Current site id will be used otherwise. * @return Resolved with if meets the requirements, rejected otherwise. */ - async checkRequiredMinimumVersion(config: CoreSitePublicConfigResponse, siteId?: string): Promise { - if (config && config.tool_mobile_minimumversion) { - const requiredVersion = this.convertVersionName(config.tool_mobile_minimumversion); - const appVersion = this.convertVersionName(CoreConfigConstants.versionname); + async checkRequiredMinimumVersion(config?: CoreSitePublicConfigResponse, siteId?: string): Promise { + if (!config || !config.tool_mobile_minimumversion) { + return; + } - if (requiredVersion > appVersion) { - const storesConfig: CoreStoreConfig = { - android: config.tool_mobile_androidappid || null, - ios: config.tool_mobile_iosappid || null, - desktop: config.tool_mobile_setuplink || 'https://download.moodle.org/desktop/', - mobile: config.tool_mobile_setuplink || 'https://download.moodle.org/mobile/', - default: config.tool_mobile_setuplink, - }; + const requiredVersion = this.convertVersionName(config.tool_mobile_minimumversion); + const appVersion = this.convertVersionName(CoreConfigConstants.versionname); - const downloadUrl = CoreApp.instance.getAppStoreUrl(storesConfig); + if (requiredVersion > appVersion) { + const storesConfig: CoreStoreConfig = { + android: config.tool_mobile_androidappid, + ios: config.tool_mobile_iosappid, + desktop: config.tool_mobile_setuplink || 'https://download.moodle.org/desktop/', + mobile: config.tool_mobile_setuplink || 'https://download.moodle.org/mobile/', + default: config.tool_mobile_setuplink, + }; - siteId = siteId || this.getCurrentSiteId(); + siteId = siteId || this.getCurrentSiteId(); + const downloadUrl = CoreApp.instance.getAppStoreUrl(storesConfig); + + if (downloadUrl != null) { // Do not block interface. CoreDomUtils.instance.showConfirm( Translate.instance.instant('core.updaterequireddesc', { $a: config.tool_mobile_minimumversion }), Translate.instance.instant('core.updaterequired'), Translate.instance.instant('core.download'), - Translate.instance.instant(siteId ? 'core.mainmenu.logout' : 'core.cancel')).then(() => { - CoreUtils.instance.openInBrowser(downloadUrl); - }).catch(() => { + Translate.instance.instant(siteId ? 'core.mainmenu.logout' : 'core.cancel'), + ).then(() => CoreUtils.instance.openInBrowser(downloadUrl)).catch(() => { // Do nothing. }); + } else { + CoreDomUtils.instance.showAlert( + Translate.instance.instant('core.updaterequired'), + Translate.instance.instant('core.updaterequireddesc', { $a: config.tool_mobile_minimumversion }), + ); + } - if (siteId) { - // Logout if it's the currentSite. - if (siteId == this.getCurrentSiteId()) { - await this.logout(); - } - - // Always expire the token. - await this.setSiteLoggedOut(siteId, true); + if (siteId) { + // Logout if it's the currentSite. + if (siteId == this.getCurrentSiteId()) { + await this.logout(); } - throw new CoreError('Current app version is lower than required version.'); + // Always expire the token. + await this.setSiteLoggedOut(siteId, true); } + + throw new CoreError('Current app version is lower than required version.'); } } @@ -952,7 +987,7 @@ export class CoreSitesProvider { return false; } catch (error) { - let config: CoreSitePublicConfigResponse; + let config: CoreSitePublicConfigResponse | undefined; try { config = await site.getPublicConfig(); @@ -979,7 +1014,7 @@ export class CoreSitesProvider { * * @return Current site. */ - getCurrentSite(): CoreSite { + getCurrentSite(): CoreSite | undefined { return this.currentSite; } @@ -1015,11 +1050,7 @@ export class CoreSitesProvider { * @return Current site User ID. */ getCurrentSiteUserId(): number { - if (this.currentSite) { - return this.currentSite.getUserId(); - } else { - return 0; - } + return this.currentSite?.getUserId() || 0; } /** @@ -1150,7 +1181,7 @@ export class CoreSitesProvider { * @param siteId The site ID. If not defined, current site (if available). * @return Promise resolved with the database. */ - getSiteDb(siteId: string): Promise { + getSiteDb(siteId?: string): Promise { return this.getSite(siteId).then((site) => site.getDb()); } @@ -1175,7 +1206,7 @@ export class CoreSitesProvider { const sites = await this.appDB.getAllRecords(SITES_TABLE); - const formattedSites = []; + const formattedSites: CoreSiteBasicInfo[] = []; sites.forEach((site) => { if (!ids || ids.indexOf(site.id) > -1) { // Parse info. @@ -1184,7 +1215,7 @@ export class CoreSitesProvider { id: site.id, siteUrl: site.siteUrl, fullName: siteInfo?.fullname, - siteName: CoreConfigConstants.sitename ? CoreConfigConstants.sitename : siteInfo?.sitename, + siteName: CoreConfigConstants.sitename ?? siteInfo?.sitename, avatar: siteInfo?.userpictureurl, siteHomeId: siteInfo?.siteid || 1, }; @@ -1206,19 +1237,23 @@ export class CoreSitesProvider { // Sort sites by url and ful lname. sites.sort((a, b) => { // First compare by site url without the protocol. - let compareA = a.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); - let compareB = b.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); - const compare = compareA.localeCompare(compareB); + const urlA = a.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); + const urlB = b.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); + const compare = urlA.localeCompare(urlB); if (compare !== 0) { return compare; } // If site url is the same, use fullname instead. - compareA = a.fullName.toLowerCase().trim(); - compareB = b.fullName.toLowerCase().trim(); + const fullNameA = a.fullName?.toLowerCase().trim(); + const fullNameB = b.fullName?.toLowerCase().trim(); - return compareA.localeCompare(compareB); + if (!fullNameA || !fullNameB) { + return 0; + } + + return fullNameA.localeCompare(fullNameB); }); return sites; @@ -1279,10 +1314,10 @@ export class CoreSitesProvider { await this.dbReady; let siteId; - const promises = []; + const promises: Promise[] = []; if (this.currentSite) { - const siteConfig = this.currentSite.getStoredConfig(); + const siteConfig = this.currentSite.getStoredConfig(); siteId = this.currentSite.getId(); this.currentSite = undefined; @@ -1418,7 +1453,7 @@ export class CoreSitesProvider { } // Try to get the site config. - let config; + let config: CoreSiteConfig | undefined; try { config = await this.getSiteConfig(site); @@ -1426,10 +1461,9 @@ export class CoreSitesProvider { // Error getting config, keep the current one. } - const newValues = { + const newValues: Record = { info: JSON.stringify(info), loggedOut: site.isLoggedOut() ? 1 : 0, - config: undefined, }; if (typeof config != 'undefined') { @@ -1475,7 +1509,7 @@ export class CoreSitesProvider { // If prioritize is true, check current site first. if (prioritize && this.currentSite && this.currentSite.containsUrl(url)) { - if (!username || this.currentSite.getInfo().username == username) { + if (!username || this.currentSite?.getInfo()?.username == username) { return [this.currentSite.getId()]; } } @@ -1498,8 +1532,8 @@ export class CoreSitesProvider { try { const siteEntries = await this.appDB.getAllRecords(SITES_TABLE); - const ids = []; - const promises = []; + const ids: string[] = []; + const promises: Promise[] = []; siteEntries.forEach((site) => { if (!this.sites[site.id]) { @@ -1507,7 +1541,7 @@ export class CoreSitesProvider { } if (this.sites[site.id].containsUrl(url)) { - if (!username || this.sites[site.id].getInfo().username == username) { + if (!username || this.sites[site.id].getInfo()?.username == username) { ids.push(site.id); } } @@ -1553,15 +1587,13 @@ export class CoreSitesProvider { * @param site The site to get the config. * @return Promise resolved with config if available. */ - protected async getSiteConfig(site: CoreSite): Promise { + protected async getSiteConfig(site: CoreSite): Promise { if (!site.wsAvailable('tool_mobile_get_config')) { // WS not available, cannot get config. return; } - const config = await site.getConfig(undefined, true); - - return config; + return await site.getConfig(undefined, true); } /** @@ -1611,7 +1643,7 @@ export class CoreSitesProvider { wsAvailableInCurrentSite(method: string, checkPrefix: boolean = true): boolean { const site = this.getCurrentSite(); - return site && site.wsAvailable(method, checkPrefix); + return site ? site.wsAvailable(method, checkPrefix) : false; } /** @@ -1659,6 +1691,10 @@ export class CoreSitesProvider { * @return Promise resolved when done. */ migrateSiteSchemas(site: CoreSite): Promise { + if (!site.id) { + return Promise.resolve(); + } + if (this.siteSchemasMigration[site.id]) { return this.siteSchemasMigration[site.id]; } @@ -1672,7 +1708,7 @@ export class CoreSitesProvider { this.siteSchemasMigration[site.id] = promise; return promise.finally(() => { - delete this.siteSchemasMigration[site.id]; + delete this.siteSchemasMigration[site.id!]; }); } @@ -1694,7 +1730,7 @@ export class CoreSitesProvider { versions[record.name] = record.version; }); - const promises = []; + const promises: Promise[] = []; for (const name in schemas) { const schema = schemas[name]; const oldVersion = versions[name] || 0; @@ -1720,6 +1756,10 @@ export class CoreSitesProvider { * @return Promise resolved when done. */ protected async applySiteSchema(site: CoreSite, schema: CoreRegisteredSiteSchema, oldVersion: number): Promise { + if (!site.id) { + return; + } + const db = site.getDb(); if (schema.tables) { @@ -1741,31 +1781,31 @@ export class CoreSitesProvider { * @return Promise resolved with site to use and the list of sites that have * the URL. Site will be undefined if it isn't the root URL of any stored site. */ - isStoredRootURL(url: string, username?: string): Promise<{site: CoreSite; siteIds: string[]}> { + async isStoredRootURL(url: string, username?: string): Promise<{site?: CoreSite; siteIds: string[]}> { // Check if the site is stored. - return this.getSiteIdsFromUrl(url, true, username).then((siteIds) => { - const result = { - siteIds, - site: undefined, - }; + const siteIds = await this.getSiteIdsFromUrl(url, true, username); - if (siteIds.length > 0) { - // If more than one site is returned it usually means there are different users stored. Use any of them. - return this.getSite(siteIds[0]).then((site) => { - const siteUrl = CoreTextUtils.instance.removeEndingSlash( - CoreUrlUtils.instance.removeProtocolAndWWW(site.getURL())); - const treatedUrl = CoreTextUtils.instance.removeEndingSlash(CoreUrlUtils.instance.removeProtocolAndWWW(url)); - - if (siteUrl == treatedUrl) { - result.site = site; - } - - return result; - }); - } + const result: {site?: CoreSite; siteIds: string[]} = { + siteIds, + }; + if (!siteIds.length) { return result; - }); + } + + // If more than one site is returned it usually means there are different users stored. Use any of them. + const site = await this.getSite(siteIds[0]); + + const siteUrl = CoreTextUtils.instance.removeEndingSlash( + CoreUrlUtils.instance.removeProtocolAndWWW(site.getURL()), + ); + const treatedUrl = CoreTextUtils.instance.removeEndingSlash(CoreUrlUtils.instance.removeProtocolAndWWW(url)); + + if (siteUrl == treatedUrl) { + result.site = site; + } + + return result; } /** @@ -1775,12 +1815,12 @@ export class CoreSitesProvider { * @return Name of the site schemas. */ getSiteTableSchemasToClear(site: CoreSite): string[] { - let reset = []; + let reset: string[] = []; for (const name in this.siteSchemas) { const schema = this.siteSchemas[name]; if (schema.canBeCleared && (!schema.siteId || site.getId() == schema.siteId)) { - reset = reset.concat(this.siteSchemas[name].canBeCleared); + reset = reset.concat(schema.canBeCleared); } } @@ -1900,17 +1940,17 @@ export type CoreSiteBasicInfo = { /** * User's full name. */ - fullName: string; + fullName?: string; /** * Site's name. */ - siteName: string; + siteName?: string; /** * User's avatar. */ - avatar: string; + avatar?: string; /** * Badge to display in the site. diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts index 97b4e3472..b2e617bb6 100644 --- a/src/app/services/utils/dom.ts +++ b/src/app/services/utils/dom.ts @@ -30,6 +30,7 @@ import { CoreConstants } from '@core/constants'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreError } from '@classes/errors/error'; +import { CoreSilentError } from '@classes/errors/silenterror'; import { makeSingleton, Translate, AlertController, LoadingController, ToastController } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; @@ -40,14 +41,15 @@ import { CoreLogger } from '@singletons/logger'; @Injectable() export class CoreDomUtilsProvider { + protected readonly INSTANCE_ID_ATTR_NAME = 'core-instance-id'; + // List of input types that support keyboard. protected readonly INPUT_SUPPORT_KEYBOARD: string[] = ['date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', 'search', 'tel', 'text', 'time', 'url', 'week']; - protected readonly INSTANCE_ID_ATTR_NAME: string = 'core-instance-id'; protected template: HTMLTemplateElement = document.createElement('template'); // A template element to convert HTML to element. - protected matchesFn: string; // Name of the "matches" function to use when simulating a closest call. + protected matchesFunctionName?: string; // Name of the "matches" function to use when simulating a closest call. // eslint-disable-next-line @typescript-eslint/no-explicit-any protected instances: {[id: string]: any} = {}; // Store component/directive instances by id. protected lastInstanceId = 0; @@ -58,10 +60,17 @@ export class CoreDomUtilsProvider { constructor(protected domSanitizer: DomSanitizer) { this.logger = CoreLogger.getInstance('CoreDomUtilsProvider'); + this.init(); + } + + /** + * Init some properties. + */ + protected async init(): Promise { // Check if debug messages should be displayed. - CoreConfig.instance.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { - this.debugDisplay = !!debugDisplay; - }); + const debugDisplay = await CoreConfig.instance.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, 0); + + this.debugDisplay = debugDisplay != 0; } /** @@ -73,17 +82,21 @@ export class CoreDomUtilsProvider { * @param selector Selector to search. * @return Closest ancestor. */ - closest(element: Element, selector: string): Element { + closest(element: Element | undefined | null, selector: string): Element | null { + if (!element) { + return null; + } + // Try to use closest if the browser supports it. if (typeof element.closest == 'function') { return element.closest(selector); } - if (!this.matchesFn) { + if (!this.matchesFunctionName) { // Find the matches function supported by the browser. ['matches', 'webkitMatchesSelector', 'mozMatchesSelector', 'msMatchesSelector', 'oMatchesSelector'].some((fn) => { if (typeof document.body[fn] == 'function') { - this.matchesFn = fn; + this.matchesFunctionName = fn; return true; } @@ -91,18 +104,22 @@ export class CoreDomUtilsProvider { return false; }); - if (!this.matchesFn) { - return; + if (!this.matchesFunctionName) { + return null; } } // Traverse parents. - while (element) { - if (element[this.matchesFn](selector)) { - return element; + let elementToTreat: Element | null = element; + + while (elementToTreat) { + if (elementToTreat[this.matchesFunctionName](selector)) { + return elementToTreat; } - element = element.parentElement; + elementToTreat = elementToTreat.parentElement; } + + return null; } /** @@ -116,7 +133,7 @@ export class CoreDomUtilsProvider { * @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise. * @return Promise resolved when the user confirms or if no confirm needed. */ - confirmDownloadSize( + async confirmDownloadSize( size: {size: number; total: boolean}, message?: string, unknownMessage?: string, @@ -126,73 +143,88 @@ export class CoreDomUtilsProvider { ): Promise { const readableSize = CoreTextUtils.instance.bytesToSize(size.size, 2); - const getAvailableBytes = new Promise((resolve): void => { + const getAvailableBytes = async (): Promise => { if (CoreApp.instance.isDesktop()) { // Free space calculation is not supported on desktop. - resolve(null); - } else { - CoreFile.instance.calculateFreeSpace().then((availableBytes) => { - if (CoreApp.instance.isAndroid()) { - return availableBytes; - } else { - // Space calculation is not accurate on iOS, but it gets more accurate when space is lower. - // We'll only use it when space is <500MB, or we're downloading more than twice the reported space. - if (availableBytes < CoreConstants.IOS_FREE_SPACE_THRESHOLD || size.size > availableBytes / 2) { - return availableBytes; - } else { - return null; - } - } - }).then((availableBytes) => { - resolve(availableBytes); - }); + return null; } - }); - const getAvailableSpace = getAvailableBytes.then((availableBytes: number) => { + const availableBytes = await CoreFile.instance.calculateFreeSpace(); + + if (CoreApp.instance.isAndroid()) { + return availableBytes; + } else { + // Space calculation is not accurate on iOS, but it gets more accurate when space is lower. + // We'll only use it when space is <500MB, or we're downloading more than twice the reported space. + if (availableBytes < CoreConstants.IOS_FREE_SPACE_THRESHOLD || size.size > availableBytes / 2) { + return availableBytes; + } else { + return null; + } + } + }; + + const getAvailableSpace = (availableBytes: number | null): string => { if (availableBytes === null) { return ''; } else { const availableSize = CoreTextUtils.instance.bytesToSize(availableBytes, 2); + if (CoreApp.instance.isAndroid() && size.size > availableBytes - CoreConstants.MINIMUM_FREE_SPACE) { - return Promise.reject(new CoreError(Translate.instance.instant('core.course.insufficientavailablespace', - { size: readableSize }))); + throw new CoreError( + Translate.instance.instant( + 'core.course.insufficientavailablespace', + { size: readableSize }, + ), + ); } return Translate.instance.instant('core.course.availablespace', { available: availableSize }); } - }); + }; - return getAvailableSpace.then((availableSpace) => { - wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; - limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; + const availableBytes = await getAvailableBytes(); - let wifiPrefix = ''; - if (CoreApp.instance.isNetworkAccessLimited()) { - wifiPrefix = Translate.instance.instant('core.course.confirmlimiteddownload'); - } + const availableSpace = getAvailableSpace(availableBytes); - if (size.size < 0 || (size.size == 0 && !size.total)) { - // Seems size was unable to be calculated. Show a warning. - unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; + wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; + limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; - return this.showConfirm(wifiPrefix + Translate.instance.instant( - unknownMessage, { availableSpace: availableSpace })); - } else if (!size.total) { - // Filesize is only partial. + let wifiPrefix = ''; + if (CoreApp.instance.isNetworkAccessLimited()) { + wifiPrefix = Translate.instance.instant('core.course.confirmlimiteddownload'); + } - return this.showConfirm(wifiPrefix + Translate.instance.instant('core.course.confirmpartialdownloadsize', - { size: readableSize, availableSpace: availableSpace })); - } else if (alwaysConfirm || size.size >= wifiThreshold || + if (size.size < 0 || (size.size == 0 && !size.total)) { + // Seems size was unable to be calculated. Show a warning. + unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; + + return this.showConfirm( + wifiPrefix + Translate.instance.instant( + unknownMessage, + { availableSpace: availableSpace }, + ), + ); + } else if (!size.total) { + // Filesize is only partial. + + return this.showConfirm( + wifiPrefix + Translate.instance.instant( + 'core.course.confirmpartialdownloadsize', + { size: readableSize, availableSpace: availableSpace }, + ), + ); + } else if (alwaysConfirm || size.size >= wifiThreshold || (CoreApp.instance.isNetworkAccessLimited() && size.size >= limitedThreshold)) { - message = message || (size.size === 0 ? 'core.course.confirmdownloadzerosize' : 'core.course.confirmdownload'); + message = message || (size.size === 0 ? 'core.course.confirmdownloadzerosize' : 'core.course.confirmdownload'); - return this.showConfirm(wifiPrefix + Translate.instance.instant(message, - { size: readableSize, availableSpace: availableSpace })); - } - - return Promise.resolve(); - }); + return this.showConfirm( + wifiPrefix + Translate.instance.instant( + message, + { size: readableSize, availableSpace: availableSpace }, + ), + ); + } } /** @@ -255,11 +287,10 @@ export class CoreDomUtilsProvider { this.logger.error('The function extractDownloadableFilesFromHtml has been moved to CoreFilepoolProvider.' + ' Please use that function instead of this one.'); - const urls = []; + const urls: string[] = []; const element = this.convertToElement(html); - const elements: (HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | - HTMLTrackElement)[] = Array.from(element.querySelectorAll('a, img, audio, video, source, track')); + const elements: AnchorOrMediaElement[] = Array.from(element.querySelectorAll('a, img, audio, video, source, track')); for (let i = 0; i < elements.length; i++) { const element = elements[i]; @@ -271,7 +302,7 @@ export class CoreDomUtilsProvider { // Treat video poster. if (element.tagName == 'VIDEO' && element.getAttribute('poster')) { - url = element.getAttribute('poster'); + url = element.getAttribute('poster') || ''; if (url && CoreUrlUtils.instance.isDownloadableUrl(url) && urls.indexOf(url) == -1) { urls.push(url); } @@ -305,7 +336,7 @@ export class CoreDomUtilsProvider { */ extractUrlsFromCSS(code: string): string[] { // First of all, search all the url(...) occurrences that don't include "data:". - const urls = []; + const urls: string[] = []; const matches = code.match(/url\(\s*["']?(?!data:)([^)]+)\)/igm); if (!matches) { @@ -394,7 +425,7 @@ export class CoreDomUtilsProvider { * @param selector Selector to search. * @return Selection contents. Undefined if not found. */ - getContentsOfElement(element: HTMLElement, selector: string): string { + getContentsOfElement(element: HTMLElement, selector: string): string | undefined { if (element) { const selected = element.querySelector(selector); if (selected) { @@ -447,7 +478,7 @@ export class CoreDomUtilsProvider { * @param attribute Attribute to get. * @return Attribute value. */ - getHTMLElementAttribute(html: string, attribute: string): string { + getHTMLElementAttribute(html: string, attribute: string): string | null { return this.convertToElement(html).children[0].getAttribute(attribute); } @@ -584,8 +615,8 @@ export class CoreDomUtilsProvider { * @param positionParentClass Parent Class where to stop calculating the position. Default inner-scroll. * @return positionLeft, positionTop of the element relative to. */ - getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] { - let element: HTMLElement = (selector ? container.querySelector(selector) : container); + getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] | null { + let element: HTMLElement | null = (selector ? container.querySelector(selector) : container); let positionTop = 0; let positionLeft = 0; @@ -645,9 +676,9 @@ export class CoreDomUtilsProvider { * @param needsTranslate Whether the error needs to be translated. * @return Error message, null if no error should be displayed. */ - getErrorMessage(error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean): string { + getErrorMessage(error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean): string | null { let extraInfo = ''; - let errorMessage: string; + let errorMessage: string | undefined; if (typeof error == 'object') { if (this.debugDisplay) { @@ -657,19 +688,21 @@ export class CoreDomUtilsProvider { } if ('backtrace' in error && error.backtrace) { extraInfo += '

' + CoreTextUtils.instance.replaceNewLines( - CoreTextUtils.instance.escapeHTML(error.backtrace, false), '
'); + CoreTextUtils.instance.escapeHTML(error.backtrace, false), + '
', + ); } // eslint-disable-next-line no-console console.error(error); } - // We received an object instead of a string. Search for common properties. - if (this.isCanceledError(error)) { - // It's a canceled error, don't display an error. + if (this.isSilentError(error)) { + // It's a silent error, don't display an error. return null; } + // We received an object instead of a string. Search for common properties. errorMessage = CoreTextUtils.instance.getErrorMessageFromError(error); if (!errorMessage) { // No common properties found, just stringify it. @@ -712,7 +745,7 @@ export class CoreDomUtilsProvider { getInstanceByElement(element: Element): any { const id = element.getAttribute(this.INSTANCE_ID_ATTR_NAME); - return this.instances[id]; + return id && this.instances[id]; } /** @@ -725,6 +758,16 @@ export class CoreDomUtilsProvider { return error instanceof CoreCanceledError; } + /** + * Check whether an error is an error caused because the user canceled a showConfirm. + * + * @param error Error to check. + * @return Whether it's a canceled error. + */ + isSilentError(error: CoreError | CoreTextErrorObject | string): boolean { + return error instanceof CoreSilentError; + } + /** * Wait an element to exists using the findFunction. * @@ -898,7 +941,7 @@ export class CoreDomUtilsProvider { */ removeInstanceByElement(element: Element): void { const id = element.getAttribute(this.INSTANCE_ID_ATTR_NAME); - delete this.instances[id]; + id && delete this.instances[id]; } /** @@ -946,7 +989,8 @@ export class CoreDomUtilsProvider { // Treat elements with src (img, audio, video, ...). const media = Array.from(element.querySelectorAll('img, video, audio, source, track')); media.forEach((media: HTMLElement) => { - let newSrc = paths[CoreTextUtils.instance.decodeURIComponent(media.getAttribute('src'))]; + const currentSrc = media.getAttribute('src'); + const newSrc = currentSrc ? paths[CoreTextUtils.instance.decodeURIComponent(currentSrc)] : undefined; if (typeof newSrc != 'undefined') { media.setAttribute('src', newSrc); @@ -954,9 +998,10 @@ export class CoreDomUtilsProvider { // Treat video posters. if (media.tagName == 'VIDEO' && media.getAttribute('poster')) { - newSrc = paths[CoreTextUtils.instance.decodeURIComponent(media.getAttribute('poster'))]; - if (typeof newSrc !== 'undefined') { - media.setAttribute('poster', newSrc); + const currentPoster = media.getAttribute('poster'); + const newPoster = paths[CoreTextUtils.instance.decodeURIComponent(currentPoster!)]; + if (typeof newPoster !== 'undefined') { + media.setAttribute('poster', newPoster); } } }); @@ -964,14 +1009,14 @@ export class CoreDomUtilsProvider { // Now treat links. const anchors = Array.from(element.querySelectorAll('a')); anchors.forEach((anchor: HTMLElement) => { - const href = CoreTextUtils.instance.decodeURIComponent(anchor.getAttribute('href')); - const newUrl = paths[href]; + const currentHref = anchor.getAttribute('href'); + const newHref = currentHref ? paths[CoreTextUtils.instance.decodeURIComponent(currentHref)] : undefined; - if (typeof newUrl != 'undefined') { - anchor.setAttribute('href', newUrl); + if (typeof newHref != 'undefined') { + anchor.setAttribute('href', newHref); if (typeof anchorFn == 'function') { - anchorFn(anchor, href); + anchorFn(anchor, newHref); } } }); @@ -990,7 +1035,7 @@ export class CoreDomUtilsProvider { * @deprecated since 3.9.5. Use directly the IonContent class. */ scrollTo(content: IonContent, x: number, y: number, duration?: number): Promise { - return content?.scrollByPoint(x, y, duration); + return content?.scrollByPoint(x, y, duration || 0); } /** @@ -1080,7 +1125,7 @@ export class CoreDomUtilsProvider { return false; } - content?.scrollByPoint(position[0], position[1], duration); + content?.scrollByPoint(position[0], position[1], duration || 0); return true; } @@ -1108,7 +1153,7 @@ export class CoreDomUtilsProvider { return false; } - content?.scrollByPoint(position[0], position[1], duration); + content?.scrollByPoint(position[0], position[1], duration || 0); return true; } catch (error) { @@ -1186,31 +1231,36 @@ export class CoreDomUtilsProvider { const alert = await AlertController.instance.create(options); + // eslint-disable-next-line promise/catch-or-return alert.present().then(() => { if (hasHTMLTags) { // Treat all anchors so they don't override the app. - const alertMessageEl: HTMLElement = alert.querySelector('.alert-message'); - this.treatAnchors(alertMessageEl); + const alertMessageEl: HTMLElement | null = alert.querySelector('.alert-message'); + alertMessageEl && this.treatAnchors(alertMessageEl); } + + return; }); // Store the alert and remove it when dismissed. this.displayedAlerts[alertId] = alert; // // Set the callbacks to trigger an observable event. + // eslint-disable-next-line promise/catch-or-return, promise/always-return alert.onDidDismiss().then(() => { delete this.displayedAlerts[alertId]; }); - if (autocloseTime > 0) { + if (autocloseTime && autocloseTime > 0) { setTimeout(async () => { await alert.dismiss(); if (options.buttons) { // Execute dismiss function if any. - const cancelButton = options.buttons.find((button) => typeof button != 'string' && - typeof button.handler != 'undefined' && button.role == 'cancel'); - cancelButton?.handler(null); + const cancelButton = options.buttons.find( + (button) => typeof button != 'string' && typeof button.handler != 'undefined' && button.role == 'cancel', + ); + cancelButton.handler?.(null); } }, autocloseTime); } @@ -1248,8 +1298,13 @@ export class CoreDomUtilsProvider { translateArgs: Record = {}, options?: AlertOptions, ): Promise { - return this.showConfirm(Translate.instance.instant(translateMessage, translateArgs), undefined, - Translate.instance.instant('core.delete'), undefined, options); + return this.showConfirm( + Translate.instance.instant(translateMessage, translateArgs), + undefined, + Translate.instance.instant('core.delete'), + undefined, + options, + ); } /** @@ -1306,7 +1361,7 @@ export class CoreDomUtilsProvider { ): Promise { if (this.isCanceledError(error)) { // It's a canceled error, don't display an error. - return null; + return Promise.resolve(null); } const message = this.getErrorMessage(error, needsTranslate); @@ -1339,7 +1394,7 @@ export class CoreDomUtilsProvider { return null; } - let errorMessage = error; + let errorMessage = error || undefined; if (error && typeof error != 'string') { errorMessage = CoreTextUtils.instance.getErrorMessageFromError(error); @@ -1428,8 +1483,8 @@ export class CoreDomUtilsProvider { const isDevice = CoreApp.instance.isAndroid() || CoreApp.instance.isIOS(); if (!isDevice) { // Treat all anchors so they don't override the app. - const alertMessageEl: HTMLElement = alert.querySelector('.alert-message'); - this.treatAnchors(alertMessageEl); + const alertMessageEl: HTMLElement | null = alert.querySelector('.alert-message'); + alertMessageEl && this.treatAnchors(alertMessageEl); } } @@ -1448,8 +1503,7 @@ export class CoreDomUtilsProvider { header?: string, placeholder?: string, type: TextFieldTypes | 'checkbox' | 'radio' | 'textarea' = 'password', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): Promise { + ): Promise { // eslint-disable-line @typescript-eslint/no-explicit-any return new Promise((resolve, reject) => { placeholder = placeholder ?? Translate.instance.instant('core.login.password'); @@ -1537,7 +1591,7 @@ export class CoreDomUtilsProvider { * @param instance The instance to store. * @return ID to identify the instance. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any storeInstanceByElement(element: Element, instance: any): string { const id = String(this.lastInstanceId++); @@ -1607,7 +1661,7 @@ export class CoreDomUtilsProvider { * @param componentId An ID to use in conjunction with the component. * @param fullScreen Whether the modal should be full screen. */ - viewImage(image: string, title?: string, component?: string, componentId?: string | number, fullScreen?: boolean): void { + viewImage(image: string, title?: string | null, component?: string, componentId?: string | number, fullScreen?: boolean): void { // @todo } @@ -1619,7 +1673,7 @@ export class CoreDomUtilsProvider { */ waitForImages(element: HTMLElement): Promise { const imgs = Array.from(element.querySelectorAll('img')); - const promises = []; + const promises: Promise[] = []; let hasImgToLoad = false; imgs.forEach((img) => { @@ -1651,7 +1705,7 @@ export class CoreDomUtilsProvider { */ wrapElement(el: HTMLElement, wrapper: HTMLElement): void { // Insert the wrapper before the element. - el.parentNode.insertBefore(wrapper, el); + el.parentNode?.insertBefore(wrapper, el); // Now move the element into the wrapper. wrapper.appendChild(el); } @@ -1680,7 +1734,7 @@ export class CoreDomUtilsProvider { * @param online Whether the action was done in offline or not. * @param siteId The site affected. If not provided, no site affected. */ - triggerFormSubmittedEvent(formRef: ElementRef, online?: boolean, siteId?: string): void { + triggerFormSubmittedEvent(formRef: ElementRef | undefined, online?: boolean, siteId?: string): void { if (!formRef) { return; } @@ -1695,3 +1749,6 @@ export class CoreDomUtilsProvider { } export class CoreDomUtils extends makeSingleton(CoreDomUtilsProvider) {} + +type AnchorOrMediaElement = + HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | HTMLTrackElement; diff --git a/src/app/services/utils/iframe.ts b/src/app/services/utils/iframe.ts index ed8f61b72..eae0e490b 100644 --- a/src/app/services/utils/iframe.ts +++ b/src/app/services/utils/iframe.ts @@ -423,7 +423,7 @@ export class CoreIframeUtilsProvider { if (!CoreSites.instance.isLoggedIn()) { CoreUtils.instance.openInBrowser(link.href); } else { - await CoreSites.instance.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(link.href); + 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. diff --git a/src/app/services/utils/mimetype.ts b/src/app/services/utils/mimetype.ts index d6b0e0767..63414cbd8 100644 --- a/src/app/services/utils/mimetype.ts +++ b/src/app/services/utils/mimetype.ts @@ -401,7 +401,7 @@ export class CoreMimetypeUtilsProvider { * @param capitalise If true, capitalises first character of result. * @return Type description. */ - getMimetypeDescription(obj: FileEntry | { filename: string; mimetype: string } | string, capitalise?: boolean): string { + getMimetypeDescription(obj: FileEntry | CoreWSExternalFile | string, capitalise?: boolean): string { const langPrefix = 'assets.mimetypes.'; let filename: string | undefined = ''; let mimetype: string | undefined = ''; diff --git a/src/app/services/utils/text.ts b/src/app/services/utils/text.ts index dd202be38..2a3984044 100644 --- a/src/app/services/utils/text.ts +++ b/src/app/services/utils/text.ts @@ -417,7 +417,7 @@ export class CoreTextUtilsProvider { * @param doubleEncode If false, it will not convert existing html entities. Defaults to true. * @return Escaped text. */ - escapeHTML(text: string | number, doubleEncode: boolean = true): string { + escapeHTML(text?: string | number | null, doubleEncode: boolean = true): string { if (typeof text == 'undefined' || text === null || (typeof text == 'number' && isNaN(text))) { return ''; } else if (typeof text != 'string') { @@ -670,7 +670,7 @@ export class CoreTextUtilsProvider { * @param text Text to treat. * @return Treated text. */ - removeEndingSlash(text: string): string { + removeEndingSlash(text?: string): string { if (!text) { return ''; } diff --git a/src/app/services/ws.ts b/src/app/services/ws.ts index a97eccea7..b1952f63b 100644 --- a/src/app/services/ws.ts +++ b/src/app/services/ws.ts @@ -44,7 +44,7 @@ import { CoreAjaxWSError } from '@classes/errors/ajaxwserror'; export class CoreWSProvider { protected logger: CoreLogger; - protected mimeTypeCache: {[url: string]: string} = {}; // A "cache" to store file mimetypes to decrease HEAD requests. + protected mimeTypeCache: {[url: string]: string | null} = {}; // A "cache" to store file mimetypes to decrease HEAD requests. // eslint-disable-next-line @typescript-eslint/no-explicit-any protected ongoingCalls: {[queueItemId: string]: Promise} = {}; protected retryCalls: RetryCall[] = []; @@ -53,11 +53,18 @@ export class CoreWSProvider { constructor() { this.logger = CoreLogger.getInstance('CoreWSProvider'); - Platform.instance.ready().then(() => { - if (CoreApp.instance.isIOS()) { - NativeHttp.instance.setHeader('*', 'User-Agent', navigator.userAgent); - } - }); + this.init(); + } + + /** + * Initialize some data. + */ + protected async init(): Promise { + await Platform.instance.ready(); + + if (CoreApp.instance.isIOS()) { + NativeHttp.instance.setHeader('*', 'User-Agent', navigator.userAgent); + } } /** @@ -67,8 +74,7 @@ export class CoreWSProvider { * @param siteUrl Complete site url to perform the call. * @param ajaxData Arguments to pass to the method. * @param preSets Extra settings and information. - * @return Deferred promise resolved with the response data in success and rejected with the error message - * if it fails. + * @return Deferred promise resolved with the response data in success and rejected with the error if it fails. */ protected addToRetryQueue(method: string, siteUrl: string, data: unknown, preSets: CoreWSPreSets): Promise { const call = { @@ -94,9 +100,9 @@ export class CoreWSProvider { */ call(method: string, data: unknown, preSets: CoreWSPreSets): Promise { if (!preSets) { - return Promise.reject(new CoreError(Translate.instance.instant('core.unexpectederror'))); + throw new CoreError(Translate.instance.instant('core.unexpectederror')); } else if (!CoreApp.instance.isOnline()) { - return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); + throw new CoreError(Translate.instance.instant('core.networkerrormsg')); } preSets.typeExpected = preSets.typeExpected || 'object'; @@ -126,10 +132,7 @@ export class CoreWSProvider { * @param method The WebService method to be called. * @param data Arguments to pass to the method. * @param preSets Extra settings and information. Only some - * @return Promise resolved with the response data in success and rejected with an object containing: - * - error: Error message. - * - errorcode: Error code returned by the site (if any). - * - available: 0 if unknown, 1 if available, -1 if not available. + * @return Promise resolved with the response data in success and rejected with CoreAjaxError. */ callAjax(method: string, data: Record, preSets: CoreWSAjaxPreSets): Promise { const cacheParams = { @@ -155,7 +158,7 @@ export class CoreWSProvider { * @param stripUnicode If Unicode long chars need to be stripped. * @return The cleaned object or null if some strings becomes empty after stripping Unicode. */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any convertValuesToString(data: any, stripUnicode?: boolean): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: any = Array.isArray(data) ? [] : {}; @@ -232,8 +235,12 @@ export class CoreWSProvider { * @param onProgress Function to call on progress. * @return Promise resolved with the downloaded file. */ - async downloadFile(url: string, path: string, addExtension?: boolean, onProgress?: (event: ProgressEvent) => void): - Promise { + async downloadFile( + url: string, + path: string, + addExtension?: boolean, + onProgress?: (event: ProgressEvent) => void, + ): Promise { this.logger.debug('Downloading file', url, path, addExtension); if (!CoreApp.instance.isOnline()) { @@ -249,7 +256,7 @@ export class CoreWSProvider { const fileEntry = await CoreFile.instance.createFile(tmpPath); const transfer = FileTransfer.instance.create(); - transfer.onProgress(onProgress); + onProgress && transfer.onProgress(onProgress); // Download the file in the tmp file. await transfer.download(url, fileEntry.toURL(), true); @@ -257,7 +264,7 @@ export class CoreWSProvider { let extension = ''; if (addExtension) { - extension = CoreMimetypeUtils.instance.getFileExtension(path); + extension = CoreMimetypeUtils.instance.getFileExtension(path) || ''; // Google Drive extensions will be considered invalid since Moodle usually converts them. if (!extension || CoreArray.contains(['gdoc', 'gsheet', 'gslides', 'gdraw', 'php'], extension)) { @@ -281,14 +288,15 @@ export class CoreWSProvider { } // Move the file to the final location. - const movedEntry: CoreWSDownloadedFileEntry = await CoreFile.instance.moveFile(tmpPath, path); + const movedEntry = await CoreFile.instance.moveFile(tmpPath, path); - // Save the extension. - movedEntry.extension = extension; - movedEntry.path = path; this.logger.debug(`Success downloading file ${url} to ${path} with extension ${extension}`); - return movedEntry; + // Also return the extension and path. + return Object.assign(movedEntry, { + extension: extension, + path: path, + }); } catch (error) { this.logger.error(`Error downloading ${url} to ${path}`, error); @@ -303,7 +311,7 @@ export class CoreWSProvider { * @param url Base URL of the HTTP request. * @param params Params of the HTTP request. */ - protected getPromiseHttp(method: string, url: string, params?: Record): Promise { + protected getPromiseHttp(method: string, url: string, params?: Record): Promise | undefined { const queueItemId = this.getQueueItemId(method, url, params); if (typeof this.ongoingCalls[queueItemId] != 'undefined') { return this.ongoingCalls[queueItemId]; @@ -317,12 +325,14 @@ export class CoreWSProvider { * @param ignoreCache True to ignore cache, false otherwise. * @return Promise resolved with the mimetype or '' if failure. */ - getRemoteFileMimeType(url: string, ignoreCache?: boolean): Promise { + async getRemoteFileMimeType(url: string, ignoreCache?: boolean): Promise { if (this.mimeTypeCache[url] && !ignoreCache) { - return Promise.resolve(this.mimeTypeCache[url]); + return this.mimeTypeCache[url]!; } - return this.performHead(url).then((response) => { + try { + const response = await this.performHead(url); + let mimeType = response.headers.get('Content-Type'); if (mimeType) { // Remove "parameters" like charset. @@ -331,10 +341,10 @@ export class CoreWSProvider { this.mimeTypeCache[url] = mimeType; return mimeType || ''; - }).catch(() => + } catch (error) { // Error, resolve with empty mimetype. - '', - ); + return ''; + } } /** @@ -345,17 +355,15 @@ export class CoreWSProvider { */ getRemoteFileSize(url: string): Promise { return this.performHead(url).then((response) => { - const size = parseInt(response.headers.get('Content-Length'), 10); + const contentLength = response.headers.get('Content-Length'); + const size = contentLength ? parseInt(contentLength, 10) : 0; if (size) { return size; } return -1; - }).catch(() => - // Error, return -1. - -1, - ); + }).catch(() => -1); } /** @@ -389,19 +397,16 @@ export class CoreWSProvider { * @param method The WebService method to be called. * @param data Arguments to pass to the method. * @param preSets Extra settings and information. Only some - * @return Promise resolved with the response data in success and rejected with an object containing: - * - error: Error message. - * - errorcode: Error code returned by the site (if any). - * - available: 0 if unknown, 1 if available, -1 if not available. + * @return Promise resolved with the response data in success and rejected with CoreAjaxError. */ protected performAjax(method: string, data: Record, preSets: CoreWSAjaxPreSets): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any let promise: Promise>; if (typeof preSets.siteUrl == 'undefined') { - return Promise.reject(new CoreAjaxError(Translate.instance.instant('core.unexpectederror'))); + throw new CoreAjaxError(Translate.instance.instant('core.unexpectederror')); } else if (!CoreApp.instance.isOnline()) { - return Promise.reject(new CoreAjaxError(Translate.instance.instant('core.networkerrormsg'))); + throw new CoreAjaxError(Translate.instance.instant('core.networkerrormsg')); } if (typeof preSets.responseExpected == 'undefined') { @@ -446,23 +451,23 @@ export class CoreWSProvider { // Check if error. Ajax layer should always return an object (if error) or an array (if success). if (!data || typeof data != 'object') { - return Promise.reject(new CoreAjaxError(Translate.instance.instant('core.serverconnection'))); + throw new CoreAjaxError(Translate.instance.instant('core.serverconnection')); } else if (data.error) { - return Promise.reject(new CoreAjaxWSError(data)); + throw new CoreAjaxWSError(data); } // Get the first response since only one request was done. data = data[0]; if (data.error) { - return Promise.reject(new CoreAjaxWSError(data.exception)); + throw new CoreAjaxWSError(data.exception); } return data.data; }, (data) => { const available = data.status == 404 ? -1 : 0; - return Promise.reject(new CoreAjaxError(Translate.instance.instant('core.serverconnection'), available)); + throw new CoreAjaxError(Translate.instance.instant('core.serverconnection'), available); }); } @@ -522,7 +527,7 @@ export class CoreWSProvider { } if (!data) { - return Promise.reject(new CoreError(Translate.instance.instant('core.serverconnection'))); + throw new CoreError(Translate.instance.instant('core.serverconnection')); } else if (typeof data != preSets.typeExpected) { // If responseType is text an string will be returned, parse before returning. if (typeof data == 'string') { @@ -531,7 +536,7 @@ export class CoreWSProvider { if (isNaN(data)) { this.logger.warn(`Response expected type "${preSets.typeExpected}" cannot be parsed to number`); - return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); + throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); } } else if (preSets.typeExpected == 'boolean') { if (data === 'true') { @@ -541,17 +546,17 @@ export class CoreWSProvider { } else { this.logger.warn(`Response expected type "${preSets.typeExpected}" is not true or false`); - return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); + throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); } } else { this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); - return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); + throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); } } else { this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); - return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); + throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); } } @@ -561,11 +566,11 @@ export class CoreWSProvider { this.logger.error('Error calling WS', method, data); } - return Promise.reject(new CoreWSError(data)); + throw new CoreWSError(data); } if (typeof data.debuginfo != 'undefined') { - return Promise.reject(new CoreError('Error. ' + data.message)); + throw new CoreError('Error. ' + data.message); } return data; @@ -593,7 +598,7 @@ export class CoreWSProvider { return retryPromise; } - return Promise.reject(new CoreError(Translate.instance.instant('core.serverconnection'))); + throw new CoreError(Translate.instance.instant('core.serverconnection')); }); } @@ -606,7 +611,7 @@ export class CoreWSProvider { const call = this.retryCalls.shift(); // Add a delay between calls. setTimeout(() => { - call.deferred.resolve(this.performPost(call.method, call.siteUrl, call.data, call.preSets)); + call!.deferred.resolve(this.performPost(call!.method, call!.siteUrl, call!.data, call!.preSets)); this.processRetryQueue(); }, 200); } else { @@ -623,8 +628,12 @@ export class CoreWSProvider { * @param params Params of the HTTP request. * @return The promise saved. */ - protected setPromiseHttp(promise: Promise, method: string, url: string, params?: Record): - Promise { + protected setPromiseHttp( + promise: Promise, + method: string, + url: string, + params?: Record, + ): Promise { const queueItemId = this.getQueueItemId(method, url, params); this.ongoingCalls[queueItemId] = promise; @@ -652,7 +661,7 @@ export class CoreWSProvider { * @return Promise resolved with the response data in success and rejected with the error message if it fails. * @return Request response. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + // eslint-disable-next-line @typescript-eslint/no-explicit-any syncCall(method: string, data: any, preSets: CoreWSPreSets): T { if (!preSets) { throw new CoreError(Translate.instance.instant('core.unexpectederror')); @@ -728,22 +737,26 @@ export class CoreWSProvider { * @param onProgress Function to call on progress. * @return Promise resolved when uploaded. */ - uploadFile(filePath: string, options: CoreWSFileUploadOptions, preSets: CoreWSPreSets, - onProgress?: (event: ProgressEvent) => void): Promise { + async uploadFile( + filePath: string, + options: CoreWSFileUploadOptions, + preSets: CoreWSPreSets, + onProgress?: (event: ProgressEvent) => void, + ): Promise { this.logger.debug(`Trying to upload file: ${filePath}`); if (!filePath || !options || !preSets) { - return Promise.reject(new CoreError('Invalid options passed to upload file.')); + throw new CoreError('Invalid options passed to upload file.'); } if (!CoreApp.instance.isOnline()) { - return Promise.reject(new CoreError(Translate.instance.instant('core.networkerrormsg'))); + throw new CoreError(Translate.instance.instant('core.networkerrormsg')); } const uploadUrl = preSets.siteUrl + '/webservice/upload.php'; const transfer = FileTransfer.instance.create(); - transfer.onProgress(onProgress); + onProgress && transfer.onProgress(onProgress); options.httpMethod = 'POST'; options.params = { @@ -755,45 +768,51 @@ export class CoreWSProvider { options.headers = {}; options['Connection'] = 'close'; - return transfer.upload(filePath, uploadUrl, options, true).then((success) => { - const data = CoreTextUtils.instance.parseJSON(success.response, null, - this.logger.error.bind(this.logger, 'Error parsing response from upload', success.response)); + try { + const success = await transfer.upload(filePath, uploadUrl, options, true); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = CoreTextUtils.instance.parseJSON( + success.response, + null, + this.logger.error.bind(this.logger, 'Error parsing response from upload', success.response), + ); if (data === null) { - return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); + throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); } if (!data) { - return Promise.reject(new CoreError(Translate.instance.instant('core.serverconnection'))); + throw new CoreError(Translate.instance.instant('core.serverconnection')); } else if (typeof data != 'object') { this.logger.warn('Upload file: Response of type "' + typeof data + '" received, expecting "object"'); - return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); + throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); } if (typeof data.exception !== 'undefined') { - return Promise.reject(new CoreWSError(data)); + throw new CoreWSError(data); } else if (typeof data.error !== 'undefined') { - return Promise.reject(new CoreWSError({ + throw new CoreWSError({ errorcode: data.errortype, message: data.error, - })); + }); } else if (data[0] && typeof data[0].error !== 'undefined') { - return Promise.reject(new CoreWSError({ + throw new CoreWSError({ errorcode: data[0].errortype, message: data[0].error, - })); + }); } // We uploaded only 1 file, so we only return the first file returned. this.logger.debug('Successfully uploaded file', filePath); return data[0]; - }).catch((error) => { + } catch (error) { this.logger.error('Error while uploading file', filePath, error); - return Promise.reject(new CoreError(Translate.instance.instant('core.errorinvalidresponse'))); - }); + throw new CoreError(Translate.instance.instant('core.errorinvalidresponse')); + } } /** @@ -842,7 +861,7 @@ export class CoreWSProvider { return new HttpResponse({ body: content, - headers: null, + headers: undefined, status: 200, statusText: 'OK', url, @@ -890,7 +909,7 @@ export class CoreWSProvider { break; default: - return Promise.reject(new CoreError('Method not implemented yet.')); + throw new CoreError('Method not implemented yet.'); } if (angularOptions.timeout) { @@ -966,6 +985,11 @@ export type CoreWSExternalWarning = { * Structure of files returned by WS. */ export type CoreWSExternalFile = { + /** + * Downloadable file url. + */ + fileurl: string; + /** * File name. */ @@ -981,11 +1005,6 @@ export type CoreWSExternalFile = { */ filesize?: number; - /** - * Downloadable file url. - */ - fileurl?: string; - /** * Time modified. */ @@ -1108,7 +1127,7 @@ export type HttpRequestOptions = { /** * Timeout for the request in seconds. If undefined, the default value will be used. If null, no timeout. */ - timeout?: number | null; + timeout?: number; /** * Serializer to use. Defaults to 'urlencoded'. Only for mobile environments. @@ -1162,6 +1181,6 @@ type RetryCall = { * Downloaded file entry. It includes some calculated data. */ export type CoreWSDownloadedFileEntry = FileEntry & { - extension?: string; // File extension. - path?: string; // File path. + extension: string; // File extension. + path: string; // File path. }; diff --git a/src/app/singletons/array.ts b/src/app/singletons/array.ts index 1e3da6b62..7f31bafc7 100644 --- a/src/app/singletons/array.ts +++ b/src/app/singletons/array.ts @@ -41,7 +41,7 @@ export class CoreArray { return (arr as any).flat(); // eslint-disable-line @typescript-eslint/no-explicit-any } - return [].concat(...arr); + return ( []).concat(...arr); } /** diff --git a/src/app/singletons/locutus.ts b/src/app/singletons/locutus.ts index 8f11cc7a1..c39484399 100644 --- a/src/app/singletons/locutus.ts +++ b/src/app/singletons/locutus.ts @@ -5,7 +5,7 @@ */ function initCache () { - const store = [] + const store: any[] = [] // cache only first element, second is length to jump ahead for the parser const cache = function cache (value) { store.push(value[0]) @@ -316,7 +316,7 @@ function expectArrayItems (str, expectedItems = 0, cache) { let hasStringKeys = false let item let totalOffset = 0 - let items = [] + let items: any[] = [] cache([items]) for (let i = 0; i < expectedItems; i++) { diff --git a/src/app/singletons/url.ts b/src/app/singletons/url.ts index d17af5bc6..3cb945572 100644 --- a/src/app/singletons/url.ts +++ b/src/app/singletons/url.ts @@ -141,7 +141,7 @@ export class CoreUrl { // If nothing else worked, parse the domain. const urlParts = CoreUrl.parse(url); - return urlParts && urlParts.domain ? urlParts.domain : null; + return urlParts?.domain ? urlParts.domain : null; } /** @@ -196,8 +196,8 @@ export class CoreUrl { 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); + return partsA?.domain == partsB?.domain && + CoreTextUtils.instance.removeEndingSlash(partsA?.path) == CoreTextUtils.instance.removeEndingSlash(partsB?.path); } } diff --git a/src/app/singletons/window.ts b/src/app/singletons/window.ts index 59b6542e6..c3a5762e2 100644 --- a/src/app/singletons/window.ts +++ b/src/app/singletons/window.ts @@ -60,7 +60,7 @@ export class CoreWindow { await CoreUtils.instance.openFile(url); } else { - let treated: boolean; + let treated = false; // eslint-disable-next-line @typescript-eslint/no-unused-vars options = options || {}; @@ -76,7 +76,7 @@ export class CoreWindow { // Not logged in, cannot auto-login. CoreUtils.instance.openInBrowser(url); } else { - await CoreSites.instance.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); + await CoreSites.instance.getCurrentSite()!.openInBrowserWithAutoLoginIfSameSite(url); } } } From 44da5c36d12964be694bf3e4481f84dc1133ca81 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 22 Oct 2020 09:45:39 +0200 Subject: [PATCH 09/10] MOBILE-3565 directives: Implement external-content directive --- src/app/directives/directives.module.ts | 3 + src/app/directives/external-content.ts | 364 ++++++++++++++++++++++++ src/app/directives/format-text.ts | 22 +- 3 files changed, 384 insertions(+), 5 deletions(-) create mode 100644 src/app/directives/external-content.ts diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts index 5e8a40358..e23a738a3 100644 --- a/src/app/directives/directives.module.ts +++ b/src/app/directives/directives.module.ts @@ -15,6 +15,7 @@ import { NgModule } from '@angular/core'; import { CoreAutoFocusDirective } from './auto-focus'; +import { CoreExternalContentDirective } from './external-content'; import { CoreFormatTextDirective } from './format-text'; import { CoreLongPressDirective } from './long-press.directive'; import { CoreSupressEventsDirective } from './supress-events'; @@ -22,6 +23,7 @@ import { CoreSupressEventsDirective } from './supress-events'; @NgModule({ declarations: [ CoreAutoFocusDirective, + CoreExternalContentDirective, CoreFormatTextDirective, CoreLongPressDirective, CoreSupressEventsDirective, @@ -29,6 +31,7 @@ import { CoreSupressEventsDirective } from './supress-events'; imports: [], exports: [ CoreAutoFocusDirective, + CoreExternalContentDirective, CoreFormatTextDirective, CoreLongPressDirective, CoreSupressEventsDirective, diff --git a/src/app/directives/external-content.ts b/src/app/directives/external-content.ts new file mode 100644 index 000000000..0b228312f --- /dev/null +++ b/src/app/directives/external-content.ts @@ -0,0 +1,364 @@ +// (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 { Directive, Input, AfterViewInit, ElementRef, OnChanges, SimpleChange, Output, EventEmitter } from '@angular/core'; + +import { CoreApp } from '@services/app'; +import { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { Platform } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreError } from '@classes/errors/error'; + +/** + * Directive to handle external content. + * + * This directive should be used with any element that links to external content + * which we want to have available when the app is offline. Typically media and links. + * + * If a file is downloaded, its URL will be replaced by the local file URL. + * + * From v3.5.2 this directive will also download inline styles, so it can be used in any element as long as it has inline styles. + */ +@Directive({ + selector: '[core-external-content]', +}) +export class CoreExternalContentDirective implements AfterViewInit, OnChanges { + + @Input() siteId?: string; // Site ID to use. + @Input() component?: string; // Component to link the file to. + @Input() componentId?: string | number; // Component ID to use in conjunction with the component. + @Input() src?: string; + @Input() href?: string; + @Input('target-src') targetSrc?: string; // eslint-disable-line @angular-eslint/no-input-rename + @Input() poster?: string; + // eslint-disable-next-line @angular-eslint/no-output-on-prefix + @Output() onLoad = new EventEmitter(); // Emitted when content is loaded. Only for images. + + loaded = false; + invalid = false; + protected element: Element; + protected logger: CoreLogger; + protected initialized = false; + + constructor(element: ElementRef) { + + this.element = element.nativeElement; + this.logger = CoreLogger.getInstance('CoreExternalContentDirective'); + } + + /** + * View has been initialized + */ + ngAfterViewInit(): void { + this.checkAndHandleExternalContent(); + + this.initialized = true; + } + + /** + * Listen to changes. + * + * * @param {{[name: string]: SimpleChange}} changes Changes. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes && this.initialized) { + // If any of the inputs changes, handle the content again. + this.checkAndHandleExternalContent(); + } + } + + /** + * Add a new source with a certain URL as a sibling of the current element. + * + * @param url URL to use in the source. + */ + protected addSource(url: string): void { + if (this.element.tagName !== 'SOURCE') { + return; + } + + const newSource = document.createElement('source'); + const type = this.element.getAttribute('type'); + + newSource.setAttribute('src', url); + + if (type) { + if (CoreApp.instance.isAndroid() && type == 'video/quicktime') { + // Fix for VideoJS/Chrome bug https://github.com/videojs/video.js/issues/423 . + newSource.setAttribute('type', 'video/mp4'); + } else { + newSource.setAttribute('type', type); + } + } + + this.element.parentNode?.insertBefore(newSource, this.element); + } + + /** + * Get the URL that should be handled and, if valid, handle it. + */ + protected async checkAndHandleExternalContent(): Promise { + const currentSite = CoreSites.instance.getCurrentSite(); + const siteId = this.siteId || currentSite?.getId(); + const tagName = this.element.tagName.toUpperCase(); + let targetAttr; + let url; + + // Always handle inline styles (if any). + this.handleInlineStyles(siteId); + + if (tagName === 'A' || tagName == 'IMAGE') { + targetAttr = 'href'; + url = this.href; + + } else if (tagName === 'IMG') { + targetAttr = 'src'; + url = this.src; + + } else if (tagName === 'AUDIO' || tagName === 'VIDEO' || tagName === 'SOURCE' || tagName === 'TRACK') { + targetAttr = 'src'; + url = this.targetSrc || this.src; + + if (tagName === 'VIDEO') { + if (this.poster) { + // Handle poster. + this.handleExternalContent('poster', this.poster, siteId).catch(() => { + // Ignore errors. + }); + } + } + + } else { + this.invalid = true; + + return; + } + + // Avoid handling data url's. + if (url && url.indexOf('data:') === 0) { + this.invalid = true; + this.onLoad.emit(); + this.loaded = true; + + return; + } + + try { + await this.handleExternalContent(targetAttr, url, siteId); + } catch (error) { + // Error handling content. Make sure the loaded event is triggered for images. + if (tagName === 'IMG') { + if (url) { + this.waitForLoad(); + } else { + this.onLoad.emit(); + this.loaded = true; + } + } + } + } + + /** + * Handle external content, setting the right URL. + * + * @param targetAttr Attribute to modify. + * @param url Original URL to treat. + * @param siteId Site ID. + * @return Promise resolved if the element is successfully treated. + */ + protected async handleExternalContent(targetAttr: string, url: string, siteId?: string): Promise { + + const tagName = this.element.tagName; + + if (tagName == 'VIDEO' && targetAttr != 'poster') { + const video = this.element; + if (video.textTracks) { + // It's a video with subtitles. In iOS, subtitles position is wrong so it needs to be fixed. + video.textTracks.onaddtrack = (event): void => { + const track = event.track; + if (track) { + track.oncuechange = (): void => { + if (!track.cues) { + return; + } + + const line = Platform.instance.is('tablet') || CoreApp.instance.isAndroid() ? 90 : 80; + // Position all subtitles to a percentage of video height. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Array.from(track.cues).forEach((cue: any) => { + cue.snapToLines = false; + cue.line = line; + cue.size = 100; // This solves some Android issue. + }); + // Delete listener. + track.oncuechange = null; + }; + } + }; + } + + } + + if (!url || !url.match(/^https?:\/\//i) || CoreUrlUtils.instance.isLocalFileUrl(url) || + (tagName === 'A' && !CoreUrlUtils.instance.isDownloadableUrl(url))) { + + this.logger.debug('Ignoring non-downloadable URL: ' + url); + if (tagName === 'SOURCE') { + // Restoring original src. + this.addSource(url); + } + + throw new CoreError('Non-downloadable URL'); + } + + const site = await CoreSites.instance.getSite(siteId); + + if (!site.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(url)) { + this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken. + + throw 'Site doesn\'t allow downloading files.'; + } + + // Download images, tracks and posters if size is unknown. + const downloadUnknown = tagName == 'IMG' || tagName == 'TRACK' || targetAttr == 'poster'; + let finalUrl: string; + + if (targetAttr === 'src' && tagName !== 'SOURCE' && tagName !== 'TRACK' && tagName !== 'VIDEO' && tagName !== 'AUDIO') { + finalUrl = await CoreFilepool.instance.getSrcByUrl( + site.getId(), + url, + this.component, + this.componentId, + 0, + true, + downloadUnknown, + ); + } else { + finalUrl = await CoreFilepool.instance.getUrlByUrl( + site.getId(), + url, + this.component, + this.componentId, + 0, + true, + downloadUnknown, + ); + + finalUrl = CoreFile.instance.convertFileSrc(finalUrl); + } + + if (!CoreUrlUtils.instance.isLocalFileUrl(finalUrl)) { + /* In iOS, if we use the same URL in embedded file and background download then the download only + downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */ + finalUrl = finalUrl + '#moodlemobile-embedded'; + } + + this.logger.debug('Using URL ' + finalUrl + ' for ' + url); + if (tagName === 'SOURCE') { + // The browser does not catch changes in SRC, we need to add a new source. + this.addSource(finalUrl); + } else { + if (tagName === 'IMG') { + this.loaded = false; + this.waitForLoad(); + } + this.element.setAttribute(targetAttr, finalUrl); + this.element.setAttribute('data-original-' + targetAttr, url); + } + + // Set events to download big files (not downloaded automatically). + if (!CoreUrlUtils.instance.isLocalFileUrl(finalUrl) && targetAttr != 'poster' && + (tagName == 'VIDEO' || tagName == 'AUDIO' || tagName == 'A' || tagName == 'SOURCE')) { + const eventName = tagName == 'A' ? 'click' : 'play'; + let clickableEl = this.element; + + if (tagName == 'SOURCE') { + clickableEl = CoreDomUtils.instance.closest(this.element, 'video,audio'); + if (!clickableEl) { + return; + } + } + + clickableEl.addEventListener(eventName, () => { + // User played media or opened a downloadable link. + // Download the file if in wifi and it hasn't been downloaded already (for big files). + if (CoreApp.instance.isWifi()) { + // We aren't using the result, so it doesn't matter which of the 2 functions we call. + CoreFilepool.instance.getUrlByUrl(site.getId(), url, this.component, this.componentId, 0, false); + } + }); + } + } + + /** + * Handle inline styles, trying to download referenced files. + * + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async handleInlineStyles(siteId?: string): Promise { + if (!siteId) { + return; + } + + let inlineStyles = this.element.getAttribute('style'); + + if (!inlineStyles) { + return; + } + + let urls = inlineStyles.match(/https?:\/\/[^"') ;]*/g); + if (!urls || !urls.length) { + return; + } + + urls = CoreUtils.instance.uniqueArray(urls); // Remove duplicates. + + const promises = urls.map(async (url) => { + const finalUrl = await CoreFilepool.instance.getUrlByUrl(siteId, url, this.component, this.componentId, 0, true, true); + + this.logger.debug('Using URL ' + finalUrl + ' for ' + url + ' in inline styles'); + inlineStyles = inlineStyles!.replace(new RegExp(url, 'gi'), finalUrl); + }); + + try { + await CoreUtils.instance.allPromises(promises); + + this.element.setAttribute('style', inlineStyles); + } catch (error) { + this.logger.error('Error treating inline styles.', this.element); + } + } + + /** + * Wait for the image to be loaded or error, and emit an event when it happens. + */ + protected waitForLoad(): void { + const listener = (): void => { + this.element.removeEventListener('load', listener); + this.element.removeEventListener('error', listener); + this.onLoad.emit(); + this.loaded = true; + }; + + this.element.addEventListener('load', listener); + this.element.addEventListener('error', listener); + } + +} diff --git a/src/app/directives/format-text.ts b/src/app/directives/format-text.ts index 7c679563e..e12d6479d 100644 --- a/src/app/directives/format-text.ts +++ b/src/app/directives/format-text.ts @@ -23,6 +23,7 @@ import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { CoreSite } from '@classes/site'; import { Translate } from '@singletons/core.singletons'; +import { CoreExternalContentDirective } from './external-content'; /** * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective @@ -91,10 +92,21 @@ export class CoreFormatTextDirective implements OnChanges { * @param element Element to add the attributes to. * @return External content instance. */ - protected addExternalContent(element: Element): any { - // Angular 2 doesn't let adding directives dynamically. Create the CoreExternalContentDirective manually. - // @todo - return null; + protected addExternalContent(element: Element): CoreExternalContentDirective { + // Angular doesn't let adding directives dynamically. Create the CoreExternalContentDirective manually. + const extContent = new CoreExternalContentDirective(new ElementRef(element)); + + extContent.component = this.component; + extContent.componentId = this.componentId; + extContent.siteId = this.siteId; + extContent.src = element.getAttribute('src') || undefined; + extContent.href = element.getAttribute('href') || element.getAttribute('xlink:href') || undefined; + extContent.targetSrc = element.getAttribute('target-src') || undefined; + extContent.poster = element.getAttribute('poster') || undefined; + + extContent.ngAfterViewInit(); + + return extContent; } /** @@ -442,7 +454,7 @@ export class CoreFormatTextDirective implements OnChanges { this.addExternalContent(anchor); }); - const externalImages: any[] = []; + const externalImages: CoreExternalContentDirective[] = []; if (images && images.length > 0) { // Walk through the content to find images, and add our directive. images.forEach((img: HTMLElement) => { From 34d6eca9a807ced802dc6bb54216225a3c3c67a5 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 22 Oct 2020 09:46:55 +0200 Subject: [PATCH 10/10] MOBILE-3565 login: Implements site page --- src/app/core/login/login-routing.module.ts | 5 + src/app/core/login/login.module.ts | 2 + .../pages/credentials/credentials.page.ts | 2 +- src/app/core/login/pages/init/init.page.ts | 2 +- src/app/core/login/pages/site/site.page.ts | 2 +- src/app/core/login/pages/sites/sites.html | 37 +++++ src/app/core/login/pages/sites/sites.page.ts | 145 ++++++++++++++++++ src/app/core/login/pages/sites/sites.scss | 3 + src/app/core/login/services/helper.ts | 33 +++- src/assets/img/user-avatar.png | Bin 0 -> 1233 bytes 10 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 src/app/core/login/pages/sites/sites.html create mode 100644 src/app/core/login/pages/sites/sites.page.ts create mode 100644 src/app/core/login/pages/sites/sites.scss create mode 100644 src/assets/img/user-avatar.png diff --git a/src/app/core/login/login-routing.module.ts b/src/app/core/login/login-routing.module.ts index 775de336d..8b1ae6f9a 100644 --- a/src/app/core/login/login-routing.module.ts +++ b/src/app/core/login/login-routing.module.ts @@ -18,6 +18,7 @@ import { RouterModule, Routes } from '@angular/router'; import { CoreLoginCredentialsPage } from './pages/credentials/credentials.page'; import { CoreLoginInitPage } from './pages/init/init.page'; import { CoreLoginSitePage } from './pages/site/site.page'; +import { CoreLoginSitesPage } from './pages/sites/sites.page'; const routes: Routes = [ { @@ -32,6 +33,10 @@ const routes: Routes = [ path: 'credentials', component: CoreLoginCredentialsPage, }, + { + path: 'sites', + component: CoreLoginSitesPage, + }, ]; @NgModule({ diff --git a/src/app/core/login/login.module.ts b/src/app/core/login/login.module.ts index 0c18f5430..89a65c544 100644 --- a/src/app/core/login/login.module.ts +++ b/src/app/core/login/login.module.ts @@ -26,6 +26,7 @@ import { CoreLoginRoutingModule } from './login-routing.module'; import { CoreLoginCredentialsPage } from './pages/credentials/credentials.page'; import { CoreLoginInitPage } from './pages/init/init.page'; import { CoreLoginSitePage } from './pages/site/site.page'; +import { CoreLoginSitesPage } from './pages/sites/sites.page'; import { CoreLoginHelperProvider } from './services/helper'; @NgModule({ @@ -43,6 +44,7 @@ import { CoreLoginHelperProvider } from './services/helper'; CoreLoginCredentialsPage, CoreLoginInitPage, CoreLoginSitePage, + CoreLoginSitesPage, ], providers: [ CoreLoginHelperProvider, diff --git a/src/app/core/login/pages/credentials/credentials.page.ts b/src/app/core/login/pages/credentials/credentials.page.ts index 3bcc2f00e..6bb45942c 100644 --- a/src/app/core/login/pages/credentials/credentials.page.ts +++ b/src/app/core/login/pages/credentials/credentials.page.ts @@ -248,7 +248,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { CoreLoginHelper.instance.treatUserTokenError(siteUrl, error, username, password); if (error.loggedout) { - // @todo Go to sites page. + this.navCtrl.navigateRoot('/login/sites'); } else if (error.errorcode == 'forcepasswordchangenotice') { // Reset password field. this.credForm.controls.password.reset(); diff --git a/src/app/core/login/pages/init/init.page.ts b/src/app/core/login/pages/init/init.page.ts index 762be4743..2391f6c98 100644 --- a/src/app/core/login/pages/init/init.page.ts +++ b/src/app/core/login/pages/init/init.page.ts @@ -90,7 +90,7 @@ export class CoreLoginInitPage implements OnInit { // return this.loginHelper.goToSiteInitialPage(); // } - await this.navCtrl.navigateRoot('/login/site'); + await this.navCtrl.navigateRoot('/login/sites'); } } diff --git a/src/app/core/login/pages/site/site.page.ts b/src/app/core/login/pages/site/site.page.ts index 432c52a12..783f77060 100644 --- a/src/app/core/login/pages/site/site.page.ts +++ b/src/app/core/login/pages/site/site.page.ts @@ -321,7 +321,7 @@ export class CoreLoginSitePage implements OnInit { CoreLoginHelper.instance.treatUserTokenError(siteData.url, error, siteData.username, siteData.password); if (error.loggedout) { - // @todo Send the user to sites page. + this.navCtrl.navigateRoot('/login/sites'); } } finally { modal.dismiss(); diff --git a/src/app/core/login/pages/sites/sites.html b/src/app/core/login/pages/sites/sites.html new file mode 100644 index 000000000..be0a4b1b5 --- /dev/null +++ b/src/app/core/login/pages/sites/sites.html @@ -0,0 +1,37 @@ + + + + + + + {{ 'core.settings.sites' | translate }} + + + + + + + + + + + + + + {{ 'core.pictureof' | translate:{$a: site.fullName} }} + +

{{site.fullName}}

+

+

{{site.siteUrl}}

+ {{site.badge}} + + + +
+
+ + + + + +
diff --git a/src/app/core/login/pages/sites/sites.page.ts b/src/app/core/login/pages/sites/sites.page.ts new file mode 100644 index 000000000..8605780a1 --- /dev/null +++ b/src/app/core/login/pages/sites/sites.page.ts @@ -0,0 +1,145 @@ +// (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 { CoreDomUtils } from '@/app/services/utils/dom'; +import { CoreUtils } from '@/app/services/utils/utils'; +import { Component, OnInit } from '@angular/core'; + +import { CoreSiteBasicInfo, CoreSites } from '@services/sites'; +import { CoreLogger } from '@singletons/logger'; +import { CoreLoginHelper } from '../../services/helper'; + +/** + * Page that displays a "splash screen" while the app is being initialized. + */ +@Component({ + selector: 'page-core-login-sites', + templateUrl: 'sites.html', + styleUrls: ['sites.scss'], +}) +export class CoreLoginSitesPage implements OnInit { + + sites: CoreSiteBasicInfo[] = []; + showDelete = false; + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreLoginSitesPage'); + } + + /** + * Component being initialized. + * + * @return Promise resolved when done. + */ + async ngOnInit(): Promise { + const sites = await CoreUtils.instance.ignoreErrors(CoreSites.instance.getSortedSites()); + + if (!sites || sites.length == 0) { + CoreLoginHelper.instance.goToAddSite(true); + + return; + } + + // Remove protocol from the url to show more url text. + this.sites = sites.map((site) => { + site.siteUrl = site.siteUrl.replace(/^https?:\/\//, ''); + site.badge = 0; + // @todo: getSiteCounter. + + return site; + }); + + this.showDelete = false; + } + + /** + * Go to the page to add a site. + */ + add(): void { + CoreLoginHelper.instance.goToAddSite(false, true); + } + + /** + * Delete a site. + * + * @param e Click event. + * @param index Position of the site. + * @return Promise resolved when done. + */ + async deleteSite(e: Event, index: number): Promise { + e.stopPropagation(); + + const site = this.sites[index]; + const siteName = site.siteName || ''; + + // @todo: Format text: siteName. + + try { + await CoreDomUtils.instance.showDeleteConfirm('core.login.confirmdeletesite', { sitename: siteName }); + } catch (error) { + // User cancelled, stop. + return; + } + + try { + await CoreSites.instance.deleteSite(site.id); + + this.sites.splice(index, 1); + this.showDelete = false; + + // If there are no sites left, go to add site. + const hasSites = await CoreSites.instance.hasSites(); + + if (!hasSites) { + CoreLoginHelper.instance.goToAddSite(true, true); + } + } catch (error) { + this.logger.error('Error deleting site ' + site.id, error); + CoreDomUtils.instance.showErrorModalDefault(error, 'core.login.errordeletesite', true); + } + } + + /** + * Login in a site. + * + * @param siteId The site ID. + * @return Promise resolved when done. + */ + async login(siteId: string): Promise { + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const loggedIn = await CoreSites.instance.loadSite(siteId); + + if (loggedIn) { + return CoreLoginHelper.instance.goToSiteInitialPage(); + } + } catch (error) { + this.logger.error('Error loading site ' + siteId, error); + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading site.'); + } finally { + modal.dismiss(); + } + } + + /** + * Toggle delete. + */ + toggleDelete(): void { + this.showDelete = !this.showDelete; + } + +} diff --git a/src/app/core/login/pages/sites/sites.scss b/src/app/core/login/pages/sites/sites.scss new file mode 100644 index 000000000..abe0054de --- /dev/null +++ b/src/app/core/login/pages/sites/sites.scss @@ -0,0 +1,3 @@ +.item-ios .item-button[icon-only] ion-icon { + font-size: 2.1em; +} diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts index cd3f3dbdf..c01d9caf8 100644 --- a/src/app/core/login/services/helper.ts +++ b/src/app/core/login/services/helper.ts @@ -55,7 +55,8 @@ export class CoreLoginHelperProvider { waitingForBrowser = false; constructor( - private location: Location, + protected location: Location, + protected navCtrl: NavController, ) { this.logger = CoreLogger.getInstance('CoreLoginHelper'); @@ -314,7 +315,7 @@ export class CoreLoginHelperProvider { */ getLogoutLabel(site?: CoreSite): string { site = site || CoreSites.instance.getCurrentSite(); - const config = site?.getStoredConfig(); + const config = site?.getStoredConfig(); return 'core.mainmenu.' + (config && config.tool_mobile_forcelogout == '1' ? 'logout' : 'changesite'); } @@ -342,7 +343,7 @@ export class CoreLoginHelperProvider { try { // Try to get the latest config, maybe the site policy was just added or has changed. - sitePolicy = await site.getConfig('sitepolicy', true); + sitePolicy = await site.getConfig('sitepolicy', true); } catch (error) { // Cannot get config, try to get the site policy using auth_email_get_signup_settings. const settings = await CoreWS.instance.callAjax( @@ -413,8 +414,30 @@ export class CoreLoginHelperProvider { * @return Promise resolved when done. */ async goToAddSite(setRoot?: boolean, showKeyboard?: boolean): Promise { - // @todo - return Promise.resolve(); + let pageRoute: string; + let params: Params; + + if (this.isFixedUrlSet()) { + // Fixed URL is set, go to credentials page. + const fixedSites = this.getFixedSites(); + const url = typeof fixedSites == 'string' ? fixedSites : fixedSites[0].url; + + pageRoute = '/login/credentials'; + params = { siteUrl: url }; + } else { + pageRoute = '/login/site'; + params = { showKeyboard: showKeyboard }; + } + + if (setRoot) { + await this.navCtrl.navigateRoot(pageRoute, { + queryParams: params, + }); + } else { + await this.navCtrl.navigateForward(pageRoute, { + queryParams: params, + }); + } } /** diff --git a/src/assets/img/user-avatar.png b/src/assets/img/user-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..0345e26c8279b7917ad8f76c2854c965a2fd680f GIT binary patch literal 1233 zcmV;?1TOoDP)CJP_~QSd zef>L6egs_T8rMhf+j;{(EhfKt^8b1AODq4Cky#|s_UeEC3$!A?05AZQCwa!z;nI%| zkeA!<m=H2rJB#rXF85ainPvdfr-Eeu!t1Kyp?!?y#7Xs~At@_3mXXm=ArD9nCif&h2lPc%t0MujJ9Xci9&8HWOER9F+#|brJB*3GpqrV7iEzP2_`q_u^?47IfK4T%e3V-o;_<-S zcLFI)J>3mOVEG z@+3(FmgvpB92dHH3gfWQChnk!#%eBl-w`PHV#7+mu2nd9;;HsGnX@-B?! zx2sdAON_|i!$Az@nDazgJRz)5@6tQd@-`0FL+==uZs&|92A1F5wyHBC!%^7s$1+b) z2c#Z{1Zx25sB7OWSTxk5r+9aM&W6L)Ic;FvcWE>+2KgdF3E zWW>f617{eK(N`PoIB_S*J$nsQXh__(bpuuYz&P$gxY&3^Z-QL^_;UX_h0z8i$%qOI z#!x-vgJNCG7Q5riI)?~P+Cs#5Is^u&uZsN{&sk}cfacCF(_OM4T(a{%1Y!nUy7P`7 z3*~z#KxX80 z;IViO-(bvzOOs21Q+a~0e}989+lZ>Iw(HlJC#n!EVrJ^pfS}tOOMVTRu@Gw*5yg-v z2XL7h#0t%<_oK5*cHBO7bwW(k8v^uAVdV8XMrw{ewPG?+EX@;P^3g{tYlXy?J;&i< z=Q*fM4V;-Dt_Q`fO`!pL4B_faqU_m*-J{{+2INLB^NPE&UJI_e^ocuF(q73(vjLB| vQzh-NhKm{KO7|b!B`KKJ$rJUyu6N@