From 99f79b9ff9005932ca7054e2ee2002554f5ff383 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 14 May 2019 08:55:37 +0200 Subject: [PATCH] MOBILE-3013 core: Support new kind of URL schemes --- scripts/langindex.json | 1 + src/app/app.component.ts | 43 +- src/app/app.module.ts | 5 +- src/assets/lang/en.json | 1 + src/core/contentlinks/lang/en.json | 3 +- .../pages/choose-site/choose-site.ts | 51 +- src/core/contentlinks/providers/helper.ts | 52 +- .../login/pages/credentials/credentials.ts | 20 +- src/core/login/providers/helper.ts | 45 +- src/core/mainmenu/pages/menu/menu.ts | 19 +- src/providers/sites.ts | 56 ++- src/providers/urlschemes.ts | 461 ++++++++++++++++++ src/providers/utils/url.ts | 18 +- 13 files changed, 649 insertions(+), 126 deletions(-) create mode 100644 src/providers/urlschemes.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index ff7a935dd..ecdecb58d 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1264,6 +1264,7 @@ "core.contentlinks.confirmurlothersite": "local_moodlemobileapp", "core.contentlinks.errornoactions": "local_moodlemobileapp", "core.contentlinks.errornosites": "local_moodlemobileapp", + "core.contentlinks.errorredirectothersite": "local_moodlemobileapp", "core.continue": "moodle", "core.copiedtoclipboard": "local_moodlemobileapp", "core.course": "moodle", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 293c8d407..1be81e471 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -19,6 +19,9 @@ import { CoreEventsProvider } from '@providers/events'; import { CoreLangProvider } from '@providers/lang'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCustomURLSchemesProvider } from '@providers/urlschemes'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { Keyboard } from '@ionic-native/keyboard'; import { ScreenOrientation } from '@ionic-native/screen-orientation'; @@ -32,11 +35,13 @@ export class MoodleMobileApp implements OnInit { rootPage: any = 'CoreLoginInitPage'; protected logger; protected lastUrls = {}; + protected lastInAppUrl: string; constructor(private platform: Platform, logger: CoreLoggerProvider, keyboard: Keyboard, private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider, private zone: NgZone, private appProvider: CoreAppProvider, private langProvider: CoreLangProvider, private sitesProvider: CoreSitesProvider, - private screenOrientation: ScreenOrientation, app: IonicApp) { + private screenOrientation: ScreenOrientation, app: IonicApp, private urlSchemesProvider: CoreCustomURLSchemesProvider, + private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider) { this.logger = logger.getInstance('AppComponent'); platform.ready().then(() => { @@ -98,13 +103,39 @@ export class MoodleMobileApp implements OnInit { // Check URLs loaded in any InAppBrowser. this.eventsProvider.on(CoreEventsProvider.IAB_LOAD_START, (event) => { - this.loginHelper.inAppBrowserLoadStart(event.url); + // URLs with a custom scheme can be prefixed with "http://" or "https://", we need to remove this. + const url = event.url.replace(/^https?:\/\//, ''); + + if (this.urlSchemesProvider.isCustomURL(url)) { + // Close the browser if it's a valid SSO URL. + this.urlSchemesProvider.handleCustomURL(url); + this.utils.closeInAppBrowser(false); + + } else if (this.platform.is('android')) { + // Check if the URL has a custom URL scheme. In Android they need to be opened manually. + const urlScheme = this.urlUtils.getUrlProtocol(url); + if (urlScheme && urlScheme !== 'file' && urlScheme !== 'cdvfile') { + // Open in browser should launch the right app if found and do nothing if not found. + this.utils.openInBrowser(url); + + // At this point the InAppBrowser is showing a "Webpage not available" error message. + // Try to navigate to last loaded URL so this error message isn't found. + if (this.lastInAppUrl) { + this.utils.openInApp(this.lastInAppUrl); + } else { + // No last URL loaded, close the InAppBrowser. + this.utils.closeInAppBrowser(false); + } + } else { + this.lastInAppUrl = url; + } + } }); // Check InAppBrowser closed. this.eventsProvider.on(CoreEventsProvider.IAB_EXIT, () => { this.loginHelper.waitingForBrowser = false; - this.loginHelper.lastInAppUrl = ''; + this.lastInAppUrl = ''; this.loginHelper.checkLogout(); }); @@ -131,14 +162,10 @@ export class MoodleMobileApp implements OnInit { this.lastUrls[url] = Date.now(); this.eventsProvider.trigger(CoreEventsProvider.APP_LAUNCHED_URL, url); + this.urlSchemesProvider.handleCustomURL(url); }); }; - // Listen for app launched URLs. If we receive one, check if it's a SSO authentication. - this.eventsProvider.on(CoreEventsProvider.APP_LAUNCHED_URL, (url) => { - this.loginHelper.appLaunchedByURL(url); - }); - // Load custom lang strings. This cannot be done inside the lang provider because it causes circular dependencies. const loadCustomStrings = (): void => { const currentSite = this.sitesProvider.getCurrentSite(), diff --git a/src/app/app.module.ts b/src/app/app.module.ts index aecc2cbe8..4a174e542 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -59,6 +59,7 @@ import { CoreUpdateManagerProvider } from '@providers/update-manager'; import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; import { CoreSyncProvider } from '@providers/sync'; import { CoreFileHelperProvider } from '@providers/file-helper'; +import { CoreCustomURLSchemesProvider } from '@providers/urlschemes'; // Core modules. import { CoreComponentsModule } from '@components/components.module'; @@ -161,7 +162,8 @@ export const CORE_PROVIDERS: any[] = [ CoreUpdateManagerProvider, CorePluginFileDelegate, CoreSyncProvider, - CoreFileHelperProvider + CoreFileHelperProvider, + CoreCustomURLSchemesProvider ]; @NgModule({ @@ -280,6 +282,7 @@ export const CORE_PROVIDERS: any[] = [ CorePluginFileDelegate, CoreSyncProvider, CoreFileHelperProvider, + CoreCustomURLSchemesProvider, { provide: HTTP_INTERCEPTORS, useClass: CoreInterceptor, diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 373327eeb..da3cb02ed 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1264,6 +1264,7 @@ "core.contentlinks.confirmurlothersite": "This link belongs to another site. Do you want to open it?", "core.contentlinks.errornoactions": "Couldn't find an action to perform with this link.", "core.contentlinks.errornosites": "Couldn't find any site to handle this link.", + "core.contentlinks.errorredirectothersite": "The redirect URL cannot point to a different site.", "core.continue": "Continue", "core.copiedtoclipboard": "Text copied to clipboard", "core.course": "Course", diff --git a/src/core/contentlinks/lang/en.json b/src/core/contentlinks/lang/en.json index 833ba3e3a..460c6acac 100644 --- a/src/core/contentlinks/lang/en.json +++ b/src/core/contentlinks/lang/en.json @@ -3,5 +3,6 @@ "chooseaccounttoopenlink": "Choose an account to open the link with.", "confirmurlothersite": "This link belongs to another site. Do you want to open it?", "errornoactions": "Couldn't find an action to perform with this link.", - "errornosites": "Couldn't find any site to handle this link." + "errornosites": "Couldn't find any site to handle this link.", + "errorredirectothersite": "The redirect URL cannot point to a different site." } \ No newline at end of file diff --git a/src/core/contentlinks/pages/choose-site/choose-site.ts b/src/core/contentlinks/pages/choose-site/choose-site.ts index 64bfceead..b10a36bc7 100644 --- a/src/core/contentlinks/pages/choose-site/choose-site.ts +++ b/src/core/contentlinks/pages/choose-site/choose-site.ts @@ -14,10 +14,12 @@ import { Component, OnInit } from '@angular/core'; import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreContentLinksDelegate, CoreContentLinksAction } from '../../providers/delegate'; import { CoreContentLinksHelperProvider } from '../../providers/helper'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; /** * Page to display the list of sites to choose one to perform a content link action. @@ -33,10 +35,11 @@ export class CoreContentLinksChooseSitePage implements OnInit { sites: any[]; loaded: boolean; protected action: CoreContentLinksAction; + protected isRootURL: boolean; constructor(private navCtrl: NavController, navParams: NavParams, private contentLinksDelegate: CoreContentLinksDelegate, - private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, - private contentLinksHelper: CoreContentLinksHelperProvider) { + private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private translate: TranslateService, + private contentLinksHelper: CoreContentLinksHelperProvider, private loginHelper: CoreLoginHelperProvider) { this.url = navParams.get('url'); } @@ -48,19 +51,35 @@ export class CoreContentLinksChooseSitePage implements OnInit { return this.leaveView(); } - // Get the action to perform. - this.contentLinksDelegate.getActionsFor(this.url).then((actions) => { - this.action = this.contentLinksHelper.getFirstValidAction(actions); - if (!this.action) { - return Promise.reject(null); - } + // Check if it's the root URL. + this.sitesProvider.isStoredRootURL(this.url).then((data): any => { + if (data.site) { + // It's the root URL. + this.isRootURL = true; + return data.siteIds; + } else if (data.siteIds.length) { + // Not root URL, but the URL belongs to at least 1 site. Check if there is any action to treat the link. + return this.contentLinksDelegate.getActionsFor(this.url).then((actions): any => { + this.action = this.contentLinksHelper.getFirstValidAction(actions); + if (!this.action) { + return Promise.reject(this.translate.instant('core.contentlinks.errornoactions')); + } + + return this.action.sites; + }); + } else { + // No sites to treat the URL. + return Promise.reject(this.translate.instant('core.contentlinks.errornosites')); + } + }).then((siteIds) => { // Get the sites that can perform the action. - return this.sitesProvider.getSites(this.action.sites).then((sites) => { - this.sites = sites; - }); - }).catch(() => { - this.domUtils.showErrorModal('core.contentlinks.errornosites', true); + return this.sitesProvider.getSites(siteIds); + }).then((sites) => { + this.sites = sites; + + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.contentlinks.errornosites', true); this.leaveView(); }).finally(() => { this.loaded = true; @@ -80,7 +99,11 @@ export class CoreContentLinksChooseSitePage implements OnInit { * @param {string} siteId Site ID. */ siteClicked(siteId: string): void { - this.action.action(siteId, this.navCtrl); + if (this.isRootURL) { + this.loginHelper.redirect('', {}, siteId); + } else { + this.action.action(siteId, this.navCtrl); + } } /** diff --git a/src/core/contentlinks/providers/helper.ts b/src/core/contentlinks/providers/helper.ts index 8347684c9..bfd8b47b2 100644 --- a/src/core/contentlinks/providers/helper.ts +++ b/src/core/contentlinks/providers/helper.ts @@ -44,9 +44,6 @@ export class CoreContentLinksHelperProvider { private initDelegate: CoreInitDelegate, eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider, private sitePluginsProvider: CoreSitePluginsProvider, private zone: NgZone, private utils: CoreUtilsProvider) { this.logger = logger.getInstance('CoreContentLinksHelperProvider'); - - // Listen for app launched URLs. If we receive one, check if it's a content link. - eventsProvider.on(CoreEventsProvider.APP_LAUNCHED_URL, this.handleCustomUrl.bind(this)); } /** @@ -62,7 +59,7 @@ export class CoreContentLinksHelperProvider { let promise; if (checkRoot) { - promise = this.isStoredRootURL(url, username); + promise = this.sitesProvider.isStoredRootURL(url, username); } else { promise = Promise.resolve({}); } @@ -139,6 +136,7 @@ export class CoreContentLinksHelperProvider { * * @param {string} url URL to handle. * @return {boolean} True if the URL should be handled by this component, false otherwise. + * @deprecated Please use CoreCustomURLSchemesProvider.handleCustomURL instead. */ handleCustomUrl(url: string): boolean { const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link'; @@ -166,7 +164,7 @@ export class CoreContentLinksHelperProvider { // Wait for the app to be ready. this.initDelegate.ready().then(() => { // Check if it's the root URL. - return this.isStoredRootURL(url, username); + return this.sitesProvider.isStoredRootURL(url, username); }).then((data) => { if (data.site) { @@ -174,7 +172,7 @@ export class CoreContentLinksHelperProvider { modal.dismiss(); return this.handleRootURL(data.site, false); - } else if (data.hasSites) { + } else if (data.siteIds.length > 0) { modal.dismiss(); // Dismiss modal so it doesn't collide with confirms. return this.handleLink(url, username).then((treated) => { @@ -266,7 +264,7 @@ export class CoreContentLinksHelperProvider { let promise; if (checkRoot) { - promise = this.isStoredRootURL(url, username); + promise = this.sitesProvider.isStoredRootURL(url, username); } else { promise = Promise.resolve({}); } @@ -321,12 +319,14 @@ export class CoreContentLinksHelperProvider { * * @param {CoreSite} site Site to handle. * @param {boolean} [openBrowserRoot] Whether to open in browser if it's root URL and it belongs to current site. + * @param {boolean} [checkToken] Whether to check that token is the same to verify it's current site. If false or not defined, + * only the URL will be checked. * @return {Promise} Promise resolved when done. */ - handleRootURL(site: CoreSite, openBrowserRoot?: boolean): Promise { + handleRootURL(site: CoreSite, openBrowserRoot?: boolean, checkToken?: boolean): Promise { const currentSite = this.sitesProvider.getCurrentSite(); - if (currentSite && currentSite.getURL() == site.getURL()) { + if (currentSite && currentSite.getURL() == site.getURL() && (!checkToken || currentSite.getToken() == site.getToken())) { // Already logged in. if (openBrowserRoot) { return site.openInBrowserWithAutoLogin(site.getURL()); @@ -338,38 +338,4 @@ export class CoreContentLinksHelperProvider { return this.loginHelper.redirect('', {}, site.getId()); } } - - /** - * Check if a URL is the root URL of any of the stored sites. If so, return the site ID. - * - * @param {string} url URL to check. - * @param {string} username Username to check. - * @return {Promise<{site: CoreSite, hasSites: boolean}>} Promise resolved with site and whether there is any site to treat - * 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, hasSites: boolean}> { - // Check if the site is stored. - return this.sitesProvider.getSiteIdsFromUrl(url, true, username).then((siteIds) => { - const result = { - hasSites: siteIds.length > 0, - site: undefined - }; - - if (result.hasSites) { - // If more than one site is returned it usually means there are different users stored. Use any of them. - return this.sitesProvider.getSite(siteIds[0]).then((site) => { - const siteUrl = this.textUtils.removeEndingSlash(this.urlUtils.removeProtocolAndWWW(site.getURL())), - treatedUrl = this.textUtils.removeEndingSlash(this.urlUtils.removeProtocolAndWWW(url)); - - if (siteUrl == treatedUrl) { - result.site = site; - } - - return result; - }); - } - - return result; - }); - } } diff --git a/src/core/login/pages/credentials/credentials.ts b/src/core/login/pages/credentials/credentials.ts index b061c58c4..20b3d2116 100644 --- a/src/core/login/pages/credentials/credentials.ts +++ b/src/core/login/pages/credentials/credentials.ts @@ -21,8 +21,6 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreLoginHelperProvider } from '../../providers/helper'; -import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; -import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { CoreConfigConstants } from '../../../../configconstants'; @@ -56,8 +54,7 @@ export class CoreLoginCredentialsPage { constructor(private navCtrl: NavController, navParams: NavParams, fb: FormBuilder, private appProvider: CoreAppProvider, private sitesProvider: CoreSitesProvider, private loginHelper: CoreLoginHelperProvider, private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private utils: CoreUtilsProvider, - private eventsProvider: CoreEventsProvider, private contentLinksDelegate: CoreContentLinksDelegate, - private contentLinksHelper: CoreContentLinksHelperProvider) { + private eventsProvider: CoreEventsProvider) { this.siteUrl = navParams.get('siteUrl'); this.siteConfig = navParams.get('siteConfig'); @@ -220,20 +217,7 @@ export class CoreLoginCredentialsPage { this.siteId = id; - if (this.urlToOpen) { - // There's a content link to open. - return this.contentLinksDelegate.getActionsFor(this.urlToOpen, undefined, username).then((actions) => { - const action = this.contentLinksHelper.getFirstValidAction(actions); - if (action && action.sites.length) { - // Action should only have 1 site because we're filtering by username. - action.action(action.sites[0]); - } else { - return this.loginHelper.goToSiteInitialPage(); - } - }); - } else { - return this.loginHelper.goToSiteInitialPage(); - } + return this.loginHelper.goToSiteInitialPage(undefined, undefined, undefined, undefined, this.urlToOpen); }); }).catch((error) => { this.loginHelper.treatUserTokenError(siteUrl, error, username, password); diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index 4967148dd..de1c7214f 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -41,7 +41,7 @@ export interface CoreLoginSSOData { * The site's URL. * @type {string} */ - siteUrl?: string; + siteUrl: string; /** * User's token. @@ -78,7 +78,6 @@ export class CoreLoginHelperProvider { protected logger; protected isSSOConfirmShown = false; protected isOpenEditAlertShown = false; - lastInAppUrl: string; waitingForBrowser = false; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, @@ -126,6 +125,7 @@ export class CoreLoginHelperProvider { * * @param {string} url URL received. * @return {boolean} True if it's a SSO URL, false otherwise. + * @deprecated Please use CoreCustomURLSchemesProvider.handleCustomURL instead. */ appLaunchedByURL(url: string): boolean { const ssoScheme = CoreConfigConstants.customurlscheme + '://token='; @@ -437,13 +437,13 @@ export class CoreLoginHelperProvider { // There are sites stored, open sites page first to be able to go back. navCtrl.setRoot('CoreLoginSitesPage'); - return navCtrl.push(page, page, {animate: false}); + return navCtrl.push(page, params, {animate: false}); } else { if (page != 'CoreLoginSitePage') { // Open the new site page to be able to go back. navCtrl.setRoot('CoreLoginSitePage'); - return navCtrl.push(page, page, {animate: false}); + return navCtrl.push(page, params, {animate: false}); } else { // Just open the page as root. return navCtrl.setRoot(page, params); @@ -460,10 +460,11 @@ export class CoreLoginHelperProvider { * @param {string} [page] Name of the page to load after loading the main page. * @param {any} [params] Params to pass to the page. * @param {NavOptions} [options] Navigation options. + * @param {string} [url] URL to open once the main menu is loaded. * @return {Promise} Promise resolved when done. */ - goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: any, options?: NavOptions): Promise { - return this.openMainMenu(navCtrl, page, params, options); + goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: any, options?: NavOptions, url?: string): Promise { + return this.openMainMenu(navCtrl, page, params, options, url); } /** @@ -494,33 +495,10 @@ export class CoreLoginHelperProvider { * Function called when a page starts loading in any InAppBrowser window. * * @param {string} url Loaded url. + * @deprecated */ inAppBrowserLoadStart(url: string): void { - // URLs with a custom scheme can be prefixed with "http://" or "https://", we need to remove this. - url = url.replace(/^https?:\/\//, ''); - - if (this.appLaunchedByURL(url)) { - // Close the browser if it's a valid SSO URL. - this.utils.closeInAppBrowser(false); - } else if (this.platform.is('android')) { - // Check if the URL has a custom URL scheme. In Android they need to be opened manually. - const urlScheme = this.urlUtils.getUrlProtocol(url); - if (urlScheme && urlScheme !== 'file' && urlScheme !== 'cdvfile') { - // Open in browser should launch the right app if found and do nothing if not found. - this.utils.openInBrowser(url); - - // At this point the InAppBrowser is showing a "Webpage not available" error message. - // Try to navigate to last loaded URL so this error message isn't found. - if (this.lastInAppUrl) { - this.utils.openInApp(this.lastInAppUrl); - } else { - // No last URL loaded, close the InAppBrowser. - this.utils.closeInAppBrowser(false); - } - } else { - this.lastInAppUrl = url; - } - } + // This function is deprecated. } /** @@ -657,9 +635,10 @@ export class CoreLoginHelperProvider { * @param {string} page Name of the page to load. * @param {any} params Params to pass to the page. * @param {NavOptions} [options] Navigation options. + * @param {string} [url] URL to open once the main menu is loaded. * @return {Promise} Promise resolved when done. */ - protected openMainMenu(navCtrl: NavController, page: string, params: any, options?: NavOptions): Promise { + protected openMainMenu(navCtrl: NavController, page: string, params: any, options?: NavOptions, url?: string): Promise { navCtrl = navCtrl || this.appProvider.getRootNavController(); // Due to DeepLinker, we need to remove the path from the URL before going to main menu. @@ -673,7 +652,7 @@ export class CoreLoginHelperProvider { }); } else { // Open the main menu. - return navCtrl.setRoot('CoreMainMenuPage', { redirectPage: page, redirectParams: params }, options); + return navCtrl.setRoot('CoreMainMenuPage', { redirectPage: page, redirectParams: params, urlToOpen: url }, options); } } diff --git a/src/core/mainmenu/pages/menu/menu.ts b/src/core/mainmenu/pages/menu/menu.ts index 358b840bc..7db8a3b67 100644 --- a/src/core/mainmenu/pages/menu/menu.ts +++ b/src/core/mainmenu/pages/menu/menu.ts @@ -19,6 +19,8 @@ import { CoreEventsProvider } from '@providers/events'; import { CoreIonTabsComponent } from '@components/ion-tabs/ion-tabs'; import { CoreMainMenuProvider } from '../../providers/mainmenu'; import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../providers/delegate'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; /** * Page that displays the main menu of the app. @@ -40,12 +42,14 @@ export class CoreMainMenuPage implements OnDestroy { protected subscription; protected redirectObs: any; protected pendingRedirect: any; + protected urlToOpen: string; @ViewChild('mainTabs') mainTabs: CoreIonTabsComponent; constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider, navParams: NavParams, private navCtrl: NavController, private eventsProvider: CoreEventsProvider, private cdr: ChangeDetectorRef, - private mainMenuProvider: CoreMainMenuProvider) { + private mainMenuProvider: CoreMainMenuProvider, private linksDelegate: CoreContentLinksDelegate, + private linksHelper: CoreContentLinksHelperProvider) { // Check if the menu was loaded with a redirect. const redirectPage = navParams.get('redirectPage'); @@ -55,6 +59,8 @@ export class CoreMainMenuPage implements OnDestroy { redirectParams: navParams.get('redirectParams') }; } + + this.urlToOpen = navParams.get('urlToOpen'); } /** @@ -155,6 +161,17 @@ export class CoreMainMenuPage implements OnDestroy { }); this.loaded = this.menuDelegate.areHandlersLoaded(); + }); + + if (this.urlToOpen) { + // There's a content link to open. + this.linksDelegate.getActionsFor(this.urlToOpen, undefined).then((actions) => { + const action = this.linksHelper.getFirstValidAction(actions); + if (action && action.sites.length) { + // Action should only have 1 site because we're filtering by username. + action.action(action.sites[0]); + } + }); } } diff --git a/src/providers/sites.ts b/src/providers/sites.ts index abd23fad7..f673c9609 100644 --- a/src/providers/sites.ts +++ b/src/providers/sites.ts @@ -565,9 +565,14 @@ export class CoreSitesProvider { * @param {string} siteUrl The site url. * @param {string} token User's token. * @param {string} [privateToken=''] User's private token. - * @return {Promise} A promise resolved when the site is added and the user is authenticated. + * @param {boolean} [login=true] Whether to login the user in the site. Defaults to true. + * @return {Promise} A promise resolved with siteId when the site is added and the user is authenticated. */ - newSite(siteUrl: string, token: string, privateToken: string = ''): Promise { + newSite(siteUrl: string, token: string, privateToken: string = '', login: boolean = true): Promise { + if (typeof login != 'boolean') { + login = true; + } + // Create a "candidate" site to fetch the site info. const candidateSite = this.sitesFactory.makeSite(undefined, siteUrl, token, undefined, privateToken); @@ -585,13 +590,18 @@ export class CoreSitesProvider { // Try to get the site config. return this.getSiteConfig(candidateSite).then((config) => { candidateSite.setConfig(config); + // Add site to sites list. this.addSite(siteId, siteUrl, token, info, privateToken, config); - // Turn candidate site into current site. - this.currentSite = candidateSite; this.sites[siteId] = candidateSite; - // Store session. - this.login(siteId); + + if (login) { + // Turn candidate site into current site. + this.currentSite = candidateSite; + // Store session. + this.login(siteId); + } + this.eventsProvider.trigger(CoreEventsProvider.SITE_ADDED, info, siteId); return siteId; @@ -1477,4 +1487,38 @@ export class CoreSitesProvider { delete this.siteSchemasMigration[site.id]; }); } + + /** + * Check if a URL is the root URL of any of the stored sites. + * + * @param {string} url URL to check. + * @param {string} [username] Username to check. + * @return {Promise<{site: CoreSite, siteIds: string[]}>} 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[]}> { + // Check if the site is stored. + return this.getSiteIdsFromUrl(url, true, username).then((siteIds) => { + const result = { + siteIds: siteIds, + site: undefined + }; + + 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 = this.textUtils.removeEndingSlash(this.urlUtils.removeProtocolAndWWW(site.getURL())), + treatedUrl = this.textUtils.removeEndingSlash(this.urlUtils.removeProtocolAndWWW(url)); + + if (siteUrl == treatedUrl) { + result.site = site; + } + + return result; + }); + } + + return result; + }); + } } diff --git a/src/providers/urlschemes.ts b/src/providers/urlschemes.ts new file mode 100644 index 000000000..fdfda01f3 --- /dev/null +++ b/src/providers/urlschemes.ts @@ -0,0 +1,461 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from './app'; +import { CoreInitDelegate } from './init'; +import { CoreLoggerProvider } from './logger'; +import { CoreSitesProvider } from './sites'; +import { CoreDomUtilsProvider } from './utils/dom'; +import { CoreTextUtilsProvider } from './utils/text'; +import { CoreUrlUtilsProvider } from './utils/url'; +import { CoreUtilsProvider } from './utils/utils'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; +import { CoreConfigConstants } from '../configconstants'; +import { CoreConstants } from '@core/constants'; + +/** + * All params that can be in a custom URL scheme. + */ +export interface CoreCustomURLSchemesParams { + /** + * The site's URL. + * @type {string} + */ + siteUrl: string; + + /** + * User's token. If set, user will be authenticated. + * @type {string} + */ + token?: string; + + /** + * User's private token. + * @type {string} + */ + privateToken?: string; + + /** + * Username. + * @type {string} + */ + username?: string; + + /** + * URL to open once authenticated. + * @type {string} + */ + redirect?: any; + + /** + * Name of the page to go once authenticated. + * @type {string} + */ + pageName?: string; + + /** + * Params to pass to the page. + * @type {string} + */ + pageParams?: any; +} + +/* + * Provider to handle custom URL schemes. + */ +@Injectable() +export class CoreCustomURLSchemesProvider { + protected logger; + + constructor(logger: CoreLoggerProvider, private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, + private loginHelper: CoreLoginHelperProvider, private linksHelper: CoreContentLinksHelperProvider, + private initDelegate: CoreInitDelegate, private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, + private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, + private linksDelegate: CoreContentLinksDelegate, private translate: TranslateService, + private sitePluginsProvider: CoreSitePluginsProvider) { + this.logger = logger.getInstance('CoreCustomURLSchemesProvider'); + } + + /** + * Handle an URL received by custom URL scheme. + * + * @param {string} url URL to treat. + * @return {Promise} Promise resolved when done. + */ + handleCustomURL(url: string): Promise { + if (!this.isCustomURL(url)) { + return Promise.reject(null); + } + + let modal, + isSSOToken = false, + data: CoreCustomURLSchemesParams; + + // Wait for app to be ready. + return this.initDelegate.ready().then(() => { + url = this.textUtils.decodeURIComponent(url); + + // Some platforms like Windows add a slash at the end. Remove it. + // Some sites add a # at the end of the URL. If it's there, remove it. + url = url.replace(/\/?#?\/?$/, ''); + + modal = this.domUtils.showModalLoading(); + + // Get the data from the URL. + if (this.isCustomURLToken(url)) { + isSSOToken = true; + + return this.getCustomURLTokenData(url); + } else if (this.isCustomURLLink(url)) { + return this.getCustomURLLinkData(url); + } else { + return this.getCustomURLData(url); + } + }).then((result) => { + data = result; + + if (data.redirect && data.redirect.indexOf(data.siteUrl) == -1) { + // Redirect URL must belong to the same site. Reject. + return Promise.reject(this.translate.instant('core.contentlinks.errorredirectothersite')); + } + + // First of all, authenticate the user if needed. + const currentSite = this.sitesProvider.getCurrentSite(); + + if (data.token) { + if (!currentSite || currentSite.getToken() != data.token) { + return this.sitesProvider.newSite(data.siteUrl, data.token, data.privateToken, isSSOToken); + } else { + return this.sitesProvider.getCurrentSiteId(); + } + } + }).then((siteId) => { + if (isSSOToken) { + // Site created and authenticated, open the page to go. + if (data.pageName) { + // State defined, go to that state instead of site initial page. + this.appProvider.getRootNavController().push(data.pageName, data.pageParams); + } else { + this.loginHelper.goToSiteInitialPage(); + } + + return; + } + + let promise; + + if (siteId) { + // Site created, we know the site to use. + promise = Promise.resolve([siteId]); + } else { + // Check if the site is stored. + promise = this.sitesProvider.getSiteIdsFromUrl(data.siteUrl, true, data.username); + } + + return promise.then((siteIds) => { + if (siteIds.length > 1) { + // More than one site to treat the URL, let the user choose. + this.linksHelper.goToChooseSite(data.redirect || data.siteUrl); + + } else if (siteIds.length == 1) { + // Only one site, handle the link. + return this.sitesProvider.getSite(siteIds[0]).then((site) => { + if (!data.redirect) { + // No redirect, go to the root URL if needed. + + return this.linksHelper.handleRootURL(site, false, true); + } else { + // Handle the redirect link. + modal.dismiss(); // Dismiss modal so it doesn't collide with confirms. + + /* Always use the username from the site in this case. If the link has a username and a token, + this will make sure that the link is opened with the user the token belongs to. */ + const username = site.getInfo().username || data.username; + + return this.linksHelper.handleLink(data.redirect, username).then((treated) => { + if (!treated) { + this.domUtils.showErrorModal('core.contentlinks.errornoactions', true); + } + }); + } + }); + + } else { + // Site not stored. Try to add the site. + return this.sitesProvider.checkSite(data.siteUrl).then((result) => { + // Site exists. We'll allow to add it. + const ssoNeeded = this.loginHelper.isSSOLoginNeeded(result.code), + pageName = 'CoreLoginCredentialsPage', + pageParams = { + siteUrl: result.siteUrl, + username: data.username, + urlToOpen: data.redirect, + siteConfig: result.config + }; + let promise, + hasSitePluginsLoaded = false; + + modal.dismiss(); // Dismiss modal so it doesn't collide with confirms. + + if (!this.sitesProvider.isLoggedIn()) { + // Not logged in, no need to confirm. If SSO the confirm will be shown later. + promise = Promise.resolve(); + } else { + // Ask the user before changing site. + const confirmMsg = this.translate.instant('core.contentlinks.confirmurlothersite'); + promise = this.domUtils.showConfirm(confirmMsg).then(() => { + if (!ssoNeeded) { + hasSitePluginsLoaded = this.sitePluginsProvider.hasSitePluginsLoaded; + if (hasSitePluginsLoaded) { + // Store the redirect since logout will restart the app. + this.appProvider.storeRedirect(CoreConstants.NO_SITE_ID, pageName, pageParams); + } + + return this.sitesProvider.logout().catch(() => { + // Ignore errors (shouldn't happen). + }); + } + }); + } + + return promise.then(() => { + if (ssoNeeded) { + this.loginHelper.confirmAndOpenBrowserForSSOLogin( + result.siteUrl, result.code, result.service, result.config && result.config.launchurl); + } else if (!hasSitePluginsLoaded) { + return this.loginHelper.goToNoSitePage(undefined, pageName, pageParams); + } + }); + + }); + } + }); + + }).catch((error) => { + if (error && isSSOToken) { + // An error occurred, display the error and logout the user. + this.loginHelper.treatUserTokenError(data.siteUrl, error); + this.sitesProvider.logout(); + } else { + this.domUtils.showErrorModalDefault(error, this.translate.instant('core.login.invalidsite')); + } + }).finally(() => { + modal.dismiss(); + + if (isSSOToken) { + this.appProvider.finishSSOAuthentication(); + } + }); + + } + + /** + * Get the data from a custom URL scheme. The structure of the URL is: + * moodlemobile://username@domain.com?token=TOKEN&privatetoken=PRIVATETOKEN&redirect=http://domain.com/course/view.php?id=2 + * + * @param {string} url URL to treat. + * @return {Promise} Promise resolved with the data. + */ + protected getCustomURLData(url: string): Promise { + const urlScheme = CoreConfigConstants.customurlscheme + '://'; + if (url.indexOf(urlScheme) == -1) { + return Promise.reject(null); + } + + // App opened using custom URL scheme. + this.logger.debug('Treating custom URL scheme: ' + url); + + // Delete the sso scheme from the URL. + url = url.replace(urlScheme, ''); + + // Detect if there's a user specified. + const username = this.urlUtils.getUsernameFromUrl(url); + if (username) { + url = url.replace(username + '@', ''); // Remove the username from the URL. + } + + // Get the params of the URL. + const params = this.urlUtils.extractUrlParams(url); + + // Remove the params to get the site URL. + if (url.indexOf('?') != -1) { + url = url.substr(0, url.indexOf('?')); + } + + return Promise.resolve({ + siteUrl: url, + username: username, + token: params.token, + privateToken: params.privateToken, + redirect: params.redirect + }); + } + + /** + * Get the data from a "link" custom URL scheme. This kind of URL is deprecated. + * + * @param {string} url URL to treat. + * @return {Promise} Promise resolved with the data. + */ + protected getCustomURLLinkData(url: string): Promise { + const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link='; + if (url.indexOf(contentLinksScheme) == -1) { + return Promise.reject(null); + } + + // App opened using custom URL scheme. + this.logger.debug('Treating custom URL scheme with link param: ' + url); + + // Delete the sso scheme from the URL. + url = url.replace(contentLinksScheme, ''); + + // Detect if there's a user specified. + const username = this.urlUtils.getUsernameFromUrl(url); + if (username) { + url = url.replace(username + '@', ''); // Remove the username from the URL. + } + + // First of all, check if it's the root URL of a site. + return this.sitesProvider.isStoredRootURL(url, username).then((data): any => { + + if (data.site) { + // Root URL. + return { + siteUrl: data.site.getURL(), + username: username + }; + + } else if (data.siteIds.length > 0) { + // Not the root URL, but at least 1 site supports the URL. Get the site URL from the list of sites. + return this.sitesProvider.getSite(data.siteIds[0]).then((site) => { + return { + siteUrl: site.getURL(), + username: username, + redirect: url + }; + }); + + } else { + // Get the site URL. + let siteUrl = this.linksDelegate.getSiteUrl(url), + redirect = url; + + if (!siteUrl) { + // Site URL not found, use the original URL since it could be the root URL of the site. + siteUrl = url; + redirect = undefined; + } + + return { + siteUrl: siteUrl, + username: username, + redirect: redirect + }; + } + }); + } + + /** + * Get the data from a "token" custom URL scheme. This kind of URL is deprecated. + * + * @param {string} url URL to treat. + * @return {Promise} Promise resolved with the data. + */ + protected getCustomURLTokenData(url: string): Promise { + const ssoScheme = CoreConfigConstants.customurlscheme + '://token='; + if (url.indexOf(ssoScheme) == -1) { + return Promise.reject(null); + } + + if (this.appProvider.isSSOAuthenticationOngoing()) { + // Authentication ongoing, probably duplicated request. + return Promise.reject(null); + } + + if (this.appProvider.isDesktop()) { + // In desktop, make sure InAppBrowser is closed. + this.utils.closeInAppBrowser(true); + } + + // App opened using custom URL scheme. Probably an SSO authentication. + this.appProvider.startSSOAuthentication(); + this.logger.debug('App launched by URL with an SSO'); + + // Delete the sso scheme from the URL. + url = url.replace(ssoScheme, ''); + + // Some platforms like Windows add a slash at the end. Remove it. + // Some sites add a # at the end of the URL. If it's there, remove it. + url = url.replace(/\/?#?\/?$/, ''); + + // Decode from base64. + try { + url = atob(url); + } catch (err) { + // Error decoding the parameter. + this.logger.error('Error decoding parameter received for login SSO'); + + return null; + } + + return this.loginHelper.validateBrowserSSOLogin(url); + } + + /** + * Check whether a URL is a custom URL scheme. + * + * @param {string} url URL to check. + * @return {boolean} Whether it's a custom URL scheme. + */ + isCustomURL(url: string): boolean { + if (!url) { + return false; + } + + return url.indexOf(CoreConfigConstants.customurlscheme + '://') != -1; + } + + /** + * Check whether a URL is a custom URL scheme with the "link" param (deprecated). + * + * @param {string} url URL to check. + * @return {boolean} Whether it's a custom URL scheme. + */ + isCustomURLLink(url: string): boolean { + if (!url) { + return false; + } + + return url.indexOf(CoreConfigConstants.customurlscheme + '://link=') != -1; + } + + /** + * Check whether a URL is a custom URL scheme with a "token" param (deprecated). + * + * @param {string} url URL to check. + * @return {boolean} Whether it's a custom URL scheme. + */ + isCustomURLToken(url: string): boolean { + if (!url) { + return false; + } + + return url.indexOf(CoreConfigConstants.customurlscheme + '://token=') != -1; + } +} diff --git a/src/providers/utils/url.ts b/src/providers/utils/url.ts index 35a1655ba..a2c38837a 100644 --- a/src/providers/utils/url.ts +++ b/src/providers/utils/url.ts @@ -52,12 +52,28 @@ export class CoreUrlUtilsProvider { */ extractUrlParams(url: string): any { const regex = /[?&]+([^=&]+)=?([^&]*)?/gi, + subParamsPlaceholder = '@@@SUBPARAMS@@@', params: any = {}, - urlAndHash = url.split('#'); + urlAndHash = url.split('#'), + questionMarkSplit = urlAndHash[0].split('?'); + let subParams; + + if (questionMarkSplit.length > 2) { + // There is more than one question mark in the URL. This can happen if any of the params is a URL with params. + // We only want to treat the first level of params, so we'll remove this second list of params and restore it later. + questionMarkSplit.splice(0, 2); + + subParams = '?' + questionMarkSplit.join('?'); + urlAndHash[0] = urlAndHash[0].replace(subParams, subParamsPlaceholder); + } urlAndHash[0].replace(regex, (match: string, key: string, value: string): string => { params[key] = typeof value != 'undefined' ? value : ''; + if (subParams) { + params[key] = params[key].replace(subParamsPlaceholder, subParams); + } + return match; });