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.confirmurlothersite": "local_moodlemobileapp",
"core.contentlinks.errornoactions": "local_moodlemobileapp", "core.contentlinks.errornoactions": "local_moodlemobileapp",
"core.contentlinks.errornosites": "local_moodlemobileapp", "core.contentlinks.errornosites": "local_moodlemobileapp",
"core.contentlinks.errorredirectothersite": "local_moodlemobileapp",
"core.continue": "moodle", "core.continue": "moodle",
"core.copiedtoclipboard": "local_moodlemobileapp", "core.copiedtoclipboard": "local_moodlemobileapp",
"core.course": "moodle", "core.course": "moodle",

View File

@ -19,6 +19,9 @@ import { CoreEventsProvider } from '@providers/events';
import { CoreLangProvider } from '@providers/lang'; import { CoreLangProvider } from '@providers/lang';
import { CoreLoggerProvider } from '@providers/logger'; import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites'; 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 { CoreLoginHelperProvider } from '@core/login/providers/helper';
import { Keyboard } from '@ionic-native/keyboard'; import { Keyboard } from '@ionic-native/keyboard';
import { ScreenOrientation } from '@ionic-native/screen-orientation'; import { ScreenOrientation } from '@ionic-native/screen-orientation';
@ -32,11 +35,13 @@ export class MoodleMobileApp implements OnInit {
rootPage: any = 'CoreLoginInitPage'; rootPage: any = 'CoreLoginInitPage';
protected logger; protected logger;
protected lastUrls = {}; protected lastUrls = {};
protected lastInAppUrl: string;
constructor(private platform: Platform, logger: CoreLoggerProvider, keyboard: Keyboard, constructor(private platform: Platform, logger: CoreLoggerProvider, keyboard: Keyboard,
private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider, private zone: NgZone, private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider, private zone: NgZone,
private appProvider: CoreAppProvider, private langProvider: CoreLangProvider, private sitesProvider: CoreSitesProvider, 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'); this.logger = logger.getInstance('AppComponent');
platform.ready().then(() => { platform.ready().then(() => {
@ -98,13 +103,39 @@ export class MoodleMobileApp implements OnInit {
// Check URLs loaded in any InAppBrowser. // Check URLs loaded in any InAppBrowser.
this.eventsProvider.on(CoreEventsProvider.IAB_LOAD_START, (event) => { 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. // Check InAppBrowser closed.
this.eventsProvider.on(CoreEventsProvider.IAB_EXIT, () => { this.eventsProvider.on(CoreEventsProvider.IAB_EXIT, () => {
this.loginHelper.waitingForBrowser = false; this.loginHelper.waitingForBrowser = false;
this.loginHelper.lastInAppUrl = ''; this.lastInAppUrl = '';
this.loginHelper.checkLogout(); this.loginHelper.checkLogout();
}); });
@ -131,14 +162,10 @@ export class MoodleMobileApp implements OnInit {
this.lastUrls[url] = Date.now(); this.lastUrls[url] = Date.now();
this.eventsProvider.trigger(CoreEventsProvider.APP_LAUNCHED_URL, url); 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. // Load custom lang strings. This cannot be done inside the lang provider because it causes circular dependencies.
const loadCustomStrings = (): void => { const loadCustomStrings = (): void => {
const currentSite = this.sitesProvider.getCurrentSite(), 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 { CorePluginFileDelegate } from '@providers/plugin-file-delegate';
import { CoreSyncProvider } from '@providers/sync'; import { CoreSyncProvider } from '@providers/sync';
import { CoreFileHelperProvider } from '@providers/file-helper'; import { CoreFileHelperProvider } from '@providers/file-helper';
import { CoreCustomURLSchemesProvider } from '@providers/urlschemes';
// Core modules. // Core modules.
import { CoreComponentsModule } from '@components/components.module'; import { CoreComponentsModule } from '@components/components.module';
@ -161,7 +162,8 @@ export const CORE_PROVIDERS: any[] = [
CoreUpdateManagerProvider, CoreUpdateManagerProvider,
CorePluginFileDelegate, CorePluginFileDelegate,
CoreSyncProvider, CoreSyncProvider,
CoreFileHelperProvider CoreFileHelperProvider,
CoreCustomURLSchemesProvider
]; ];
@NgModule({ @NgModule({
@ -280,6 +282,7 @@ export const CORE_PROVIDERS: any[] = [
CorePluginFileDelegate, CorePluginFileDelegate,
CoreSyncProvider, CoreSyncProvider,
CoreFileHelperProvider, CoreFileHelperProvider,
CoreCustomURLSchemesProvider,
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,
useClass: CoreInterceptor, 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.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.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.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.continue": "Continue",
"core.copiedtoclipboard": "Text copied to clipboard", "core.copiedtoclipboard": "Text copied to clipboard",
"core.course": "Course", "core.course": "Course",

View File

@ -3,5 +3,6 @@
"chooseaccounttoopenlink": "Choose an account to open the link with.", "chooseaccounttoopenlink": "Choose an account to open the link with.",
"confirmurlothersite": "This link belongs to another site. Do you want to open it?", "confirmurlothersite": "This link belongs to another site. Do you want to open it?",
"errornoactions": "Couldn't find an action to perform with this link.", "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 { Component, OnInit } from '@angular/core';
import { IonicPage, NavController, NavParams } from 'ionic-angular'; import { IonicPage, NavController, NavParams } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreSitesProvider } from '@providers/sites'; import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreContentLinksDelegate, CoreContentLinksAction } from '../../providers/delegate'; import { CoreContentLinksDelegate, CoreContentLinksAction } from '../../providers/delegate';
import { CoreContentLinksHelperProvider } from '../../providers/helper'; 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. * 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[]; sites: any[];
loaded: boolean; loaded: boolean;
protected action: CoreContentLinksAction; protected action: CoreContentLinksAction;
protected isRootURL: boolean;
constructor(private navCtrl: NavController, navParams: NavParams, private contentLinksDelegate: CoreContentLinksDelegate, constructor(private navCtrl: NavController, navParams: NavParams, private contentLinksDelegate: CoreContentLinksDelegate,
private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private translate: TranslateService,
private contentLinksHelper: CoreContentLinksHelperProvider) { private contentLinksHelper: CoreContentLinksHelperProvider, private loginHelper: CoreLoginHelperProvider) {
this.url = navParams.get('url'); this.url = navParams.get('url');
} }
@ -48,19 +51,35 @@ export class CoreContentLinksChooseSitePage implements OnInit {
return this.leaveView(); return this.leaveView();
} }
// Get the action to perform. // Check if it's the root URL.
this.contentLinksDelegate.getActionsFor(this.url).then((actions) => { this.sitesProvider.isStoredRootURL(this.url).then((data): any => {
this.action = this.contentLinksHelper.getFirstValidAction(actions); if (data.site) {
if (!this.action) { // It's the root URL.
return Promise.reject(null); 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. // Get the sites that can perform the action.
return this.sitesProvider.getSites(this.action.sites).then((sites) => { return this.sitesProvider.getSites(siteIds);
this.sites = sites; }).then((sites) => {
}); this.sites = sites;
}).catch(() => {
this.domUtils.showErrorModal('core.contentlinks.errornosites', true); }).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'core.contentlinks.errornosites', true);
this.leaveView(); this.leaveView();
}).finally(() => { }).finally(() => {
this.loaded = true; this.loaded = true;
@ -80,7 +99,11 @@ export class CoreContentLinksChooseSitePage implements OnInit {
* @param {string} siteId Site ID. * @param {string} siteId Site ID.
*/ */
siteClicked(siteId: string): void { 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 initDelegate: CoreInitDelegate, eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider,
private sitePluginsProvider: CoreSitePluginsProvider, private zone: NgZone, private utils: CoreUtilsProvider) { private sitePluginsProvider: CoreSitePluginsProvider, private zone: NgZone, private utils: CoreUtilsProvider) {
this.logger = logger.getInstance('CoreContentLinksHelperProvider'); 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; let promise;
if (checkRoot) { if (checkRoot) {
promise = this.isStoredRootURL(url, username); promise = this.sitesProvider.isStoredRootURL(url, username);
} else { } else {
promise = Promise.resolve({}); promise = Promise.resolve({});
} }
@ -139,6 +136,7 @@ export class CoreContentLinksHelperProvider {
* *
* @param {string} url URL to handle. * @param {string} url URL to handle.
* @return {boolean} True if the URL should be handled by this component, false otherwise. * @return {boolean} True if the URL should be handled by this component, false otherwise.
* @deprecated Please use CoreCustomURLSchemesProvider.handleCustomURL instead.
*/ */
handleCustomUrl(url: string): boolean { handleCustomUrl(url: string): boolean {
const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link'; const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link';
@ -166,7 +164,7 @@ export class CoreContentLinksHelperProvider {
// Wait for the app to be ready. // Wait for the app to be ready.
this.initDelegate.ready().then(() => { this.initDelegate.ready().then(() => {
// Check if it's the root URL. // Check if it's the root URL.
return this.isStoredRootURL(url, username); return this.sitesProvider.isStoredRootURL(url, username);
}).then((data) => { }).then((data) => {
if (data.site) { if (data.site) {
@ -174,7 +172,7 @@ export class CoreContentLinksHelperProvider {
modal.dismiss(); modal.dismiss();
return this.handleRootURL(data.site, false); 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. modal.dismiss(); // Dismiss modal so it doesn't collide with confirms.
return this.handleLink(url, username).then((treated) => { return this.handleLink(url, username).then((treated) => {
@ -266,7 +264,7 @@ export class CoreContentLinksHelperProvider {
let promise; let promise;
if (checkRoot) { if (checkRoot) {
promise = this.isStoredRootURL(url, username); promise = this.sitesProvider.isStoredRootURL(url, username);
} else { } else {
promise = Promise.resolve({}); promise = Promise.resolve({});
} }
@ -321,12 +319,14 @@ export class CoreContentLinksHelperProvider {
* *
* @param {CoreSite} site Site to handle. * @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} [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. * @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(); 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. // Already logged in.
if (openBrowserRoot) { if (openBrowserRoot) {
return site.openInBrowserWithAutoLogin(site.getURL()); return site.openInBrowserWithAutoLogin(site.getURL());
@ -338,38 +338,4 @@ export class CoreContentLinksHelperProvider {
return this.loginHelper.redirect('', {}, site.getId()); 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 { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreLoginHelperProvider } from '../../providers/helper'; 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 { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { CoreConfigConstants } from '../../../../configconstants'; import { CoreConfigConstants } from '../../../../configconstants';
@ -56,8 +54,7 @@ export class CoreLoginCredentialsPage {
constructor(private navCtrl: NavController, navParams: NavParams, fb: FormBuilder, private appProvider: CoreAppProvider, constructor(private navCtrl: NavController, navParams: NavParams, fb: FormBuilder, private appProvider: CoreAppProvider,
private sitesProvider: CoreSitesProvider, private loginHelper: CoreLoginHelperProvider, private sitesProvider: CoreSitesProvider, private loginHelper: CoreLoginHelperProvider,
private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private utils: CoreUtilsProvider, private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private utils: CoreUtilsProvider,
private eventsProvider: CoreEventsProvider, private contentLinksDelegate: CoreContentLinksDelegate, private eventsProvider: CoreEventsProvider) {
private contentLinksHelper: CoreContentLinksHelperProvider) {
this.siteUrl = navParams.get('siteUrl'); this.siteUrl = navParams.get('siteUrl');
this.siteConfig = navParams.get('siteConfig'); this.siteConfig = navParams.get('siteConfig');
@ -220,20 +217,7 @@ export class CoreLoginCredentialsPage {
this.siteId = id; this.siteId = id;
if (this.urlToOpen) { return this.loginHelper.goToSiteInitialPage(undefined, undefined, undefined, undefined, 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();
}
}); });
}).catch((error) => { }).catch((error) => {
this.loginHelper.treatUserTokenError(siteUrl, error, username, password); this.loginHelper.treatUserTokenError(siteUrl, error, username, password);

View File

@ -41,7 +41,7 @@ export interface CoreLoginSSOData {
* The site's URL. * The site's URL.
* @type {string} * @type {string}
*/ */
siteUrl?: string; siteUrl: string;
/** /**
* User's token. * User's token.
@ -78,7 +78,6 @@ export class CoreLoginHelperProvider {
protected logger; protected logger;
protected isSSOConfirmShown = false; protected isSSOConfirmShown = false;
protected isOpenEditAlertShown = false; protected isOpenEditAlertShown = false;
lastInAppUrl: string;
waitingForBrowser = false; waitingForBrowser = false;
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider,
@ -126,6 +125,7 @@ export class CoreLoginHelperProvider {
* *
* @param {string} url URL received. * @param {string} url URL received.
* @return {boolean} True if it's a SSO URL, false otherwise. * @return {boolean} True if it's a SSO URL, false otherwise.
* @deprecated Please use CoreCustomURLSchemesProvider.handleCustomURL instead.
*/ */
appLaunchedByURL(url: string): boolean { appLaunchedByURL(url: string): boolean {
const ssoScheme = CoreConfigConstants.customurlscheme + '://token='; 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. // There are sites stored, open sites page first to be able to go back.
navCtrl.setRoot('CoreLoginSitesPage'); navCtrl.setRoot('CoreLoginSitesPage');
return navCtrl.push(page, page, {animate: false}); return navCtrl.push(page, params, {animate: false});
} else { } else {
if (page != 'CoreLoginSitePage') { if (page != 'CoreLoginSitePage') {
// Open the new site page to be able to go back. // Open the new site page to be able to go back.
navCtrl.setRoot('CoreLoginSitePage'); navCtrl.setRoot('CoreLoginSitePage');
return navCtrl.push(page, page, {animate: false}); return navCtrl.push(page, params, {animate: false});
} else { } else {
// Just open the page as root. // Just open the page as root.
return navCtrl.setRoot(page, params); 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 {string} [page] Name of the page to load after loading the main page.
* @param {any} [params] Params to pass to the page. * @param {any} [params] Params to pass to the page.
* @param {NavOptions} [options] Navigation options. * @param {NavOptions} [options] Navigation options.
* @param {string} [url] URL to open once the main menu is loaded.
* @return {Promise<any>} Promise resolved when done. * @return {Promise<any>} Promise resolved when done.
*/ */
goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: any, options?: NavOptions): Promise<any> { goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: any, options?: NavOptions, url?: string): Promise<any> {
return this.openMainMenu(navCtrl, page, params, options); 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. * Function called when a page starts loading in any InAppBrowser window.
* *
* @param {string} url Loaded url. * @param {string} url Loaded url.
* @deprecated
*/ */
inAppBrowserLoadStart(url: string): void { inAppBrowserLoadStart(url: string): void {
// URLs with a custom scheme can be prefixed with "http://" or "https://", we need to remove this. // This function is deprecated.
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;
}
}
} }
/** /**
@ -657,9 +635,10 @@ export class CoreLoginHelperProvider {
* @param {string} page Name of the page to load. * @param {string} page Name of the page to load.
* @param {any} params Params to pass to the page. * @param {any} params Params to pass to the page.
* @param {NavOptions} [options] Navigation options. * @param {NavOptions} [options] Navigation options.
* @param {string} [url] URL to open once the main menu is loaded.
* @return {Promise<any>} Promise resolved when done. * @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(); navCtrl = navCtrl || this.appProvider.getRootNavController();
// Due to DeepLinker, we need to remove the path from the URL before going to main menu. // 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 { } else {
// Open the main menu. // 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 { CoreIonTabsComponent } from '@components/ion-tabs/ion-tabs';
import { CoreMainMenuProvider } from '../../providers/mainmenu'; import { CoreMainMenuProvider } from '../../providers/mainmenu';
import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../providers/delegate'; 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. * Page that displays the main menu of the app.
@ -40,12 +42,14 @@ export class CoreMainMenuPage implements OnDestroy {
protected subscription; protected subscription;
protected redirectObs: any; protected redirectObs: any;
protected pendingRedirect: any; protected pendingRedirect: any;
protected urlToOpen: string;
@ViewChild('mainTabs') mainTabs: CoreIonTabsComponent; @ViewChild('mainTabs') mainTabs: CoreIonTabsComponent;
constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider, navParams: NavParams, constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider, navParams: NavParams,
private navCtrl: NavController, private eventsProvider: CoreEventsProvider, private cdr: ChangeDetectorRef, 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. // Check if the menu was loaded with a redirect.
const redirectPage = navParams.get('redirectPage'); const redirectPage = navParams.get('redirectPage');
@ -55,6 +59,8 @@ export class CoreMainMenuPage implements OnDestroy {
redirectParams: navParams.get('redirectParams') redirectParams: navParams.get('redirectParams')
}; };
} }
this.urlToOpen = navParams.get('urlToOpen');
} }
/** /**
@ -155,6 +161,17 @@ export class CoreMainMenuPage implements OnDestroy {
}); });
this.loaded = this.menuDelegate.areHandlersLoaded(); 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} siteUrl The site url.
* @param {string} token User's token. * @param {string} token User's token.
* @param {string} [privateToken=''] User's private 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. // Create a "candidate" site to fetch the site info.
const candidateSite = this.sitesFactory.makeSite(undefined, siteUrl, token, undefined, privateToken); const candidateSite = this.sitesFactory.makeSite(undefined, siteUrl, token, undefined, privateToken);
@ -585,13 +590,18 @@ export class CoreSitesProvider {
// Try to get the site config. // Try to get the site config.
return this.getSiteConfig(candidateSite).then((config) => { return this.getSiteConfig(candidateSite).then((config) => {
candidateSite.setConfig(config); candidateSite.setConfig(config);
// Add site to sites list. // Add site to sites list.
this.addSite(siteId, siteUrl, token, info, privateToken, config); this.addSite(siteId, siteUrl, token, info, privateToken, config);
// Turn candidate site into current site.
this.currentSite = candidateSite;
this.sites[siteId] = 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); this.eventsProvider.trigger(CoreEventsProvider.SITE_ADDED, info, siteId);
return siteId; return siteId;
@ -1477,4 +1487,38 @@ export class CoreSitesProvider {
delete this.siteSchemasMigration[site.id]; 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 { extractUrlParams(url: string): any {
const regex = /[?&]+([^=&]+)=?([^&]*)?/gi, const regex = /[?&]+([^=&]+)=?([^&]*)?/gi,
subParamsPlaceholder = '@@@SUBPARAMS@@@',
params: any = {}, 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 => { urlAndHash[0].replace(regex, (match: string, key: string, value: string): string => {
params[key] = typeof value != 'undefined' ? value : ''; params[key] = typeof value != 'undefined' ? value : '';
if (subParams) {
params[key] = params[key].replace(subParamsPlaceholder, subParams);
}
return match; return match;
}); });