MOBILE-3013 core: Support new kind of URL schemes

main
Dani Palou 2019-05-14 08:55:37 +02:00
parent cab44018a6
commit 99f79b9ff9
13 changed files with 649 additions and 126 deletions

View File

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

View File

@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

@ -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<any>} Promise resolved when done.
*/
handleRootURL(site: CoreSite, openBrowserRoot?: boolean): Promise<any> {
handleRootURL(site: CoreSite, openBrowserRoot?: boolean, checkToken?: boolean): Promise<any> {
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;
});
}
}

View File

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

View File

@ -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<any>} Promise resolved when done.
*/
goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: any, options?: NavOptions): Promise<any> {
return this.openMainMenu(navCtrl, page, params, options);
goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: any, options?: NavOptions, url?: string): Promise<any> {
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<any>} Promise resolved when done.
*/
protected openMainMenu(navCtrl: NavController, page: string, params: any, options?: NavOptions): Promise<any> {
protected openMainMenu(navCtrl: NavController, page: string, params: any, options?: NavOptions, url?: string): Promise<any> {
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);
}
}

View File

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

View File

@ -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<any>} 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<string>} A promise resolved with siteId when the site is added and the user is authenticated.
*/
newSite(siteUrl: string, token: string, privateToken: string = ''): Promise<any> {
newSite(siteUrl: string, token: string, privateToken: string = '', login: boolean = true): Promise<string> {
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;
});
}
}

View File

@ -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<any>} Promise resolved when done.
*/
handleCustomURL(url: string): Promise<any> {
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<CoreCustomURLSchemesParams>} Promise resolved with the data.
*/
protected getCustomURLData(url: string): Promise<CoreCustomURLSchemesParams> {
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<CoreCustomURLSchemesParams>} Promise resolved with the data.
*/
protected getCustomURLLinkData(url: string): Promise<CoreCustomURLSchemesParams> {
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<CoreCustomURLSchemesParams>} Promise resolved with the data.
*/
protected getCustomURLTokenData(url: string): Promise<CoreCustomURLSchemesParams> {
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;
}
}

View File

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