Merge pull request #2354 from dpalou/MOBILE-2995

Mobile 2995
main
Juan Leyva 2020-04-29 14:07:49 +02:00 committed by GitHub
commit 20c2770c12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1241 additions and 314 deletions

View File

@ -170,6 +170,7 @@
<plugin name="cordova-plugin-local-notification" spec="https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle" />
<plugin name="cordova-plugin-media-capture" spec="3.0.3" />
<plugin name="cordova-plugin-network-information" spec="2.0.2" />
<plugin name="cordova-plugin-qrscanner" spec="https://github.com/moodlemobile/cordova-plugin-qrscanner.git#dist" />
<plugin name="cordova-plugin-screen-orientation" spec="3.0.2" />
<plugin name="cordova-plugin-splashscreen" spec="5.0.3" />
<plugin name="cordova-plugin-statusbar" spec="2.4.3" />

31
package-lock.json generated
View File

@ -182,6 +182,11 @@
"resolved": "https://registry.npmjs.org/@ionic-native/push/-/push-4.20.0.tgz",
"integrity": "sha512-IgzaZd8KSPLwyLX1emRijlQ0Vfa3RlPPBx370lVH32c8zG3DFH1xfQQbb39KF3qmX5b6so0pGGA2holSUwVm2w=="
},
"@ionic-native/qr-scanner": {
"version": "4.20.0",
"resolved": "https://registry.npmjs.org/@ionic-native/qr-scanner/-/qr-scanner-4.20.0.tgz",
"integrity": "sha512-eLeJQq49/x5bdCVLotuMHZZ3YGEpSzuEnuX2vno2ugdGSygBm+wxIVSa9Nuz8HozYwC6oyii+zH/pg4SZ+4V9Q=="
},
"@ionic-native/screen-orientation": {
"version": "4.20.0",
"resolved": "https://registry.npmjs.org/@ionic-native/screen-orientation/-/screen-orientation-4.20.0.tgz",
@ -2705,6 +2710,14 @@
"resolved": "https://registry.npmjs.org/cordova-plugin-network-information/-/cordova-plugin-network-information-2.0.2.tgz",
"integrity": "sha512-NwO3qDBNL/vJxUxBTPNOA1HvkDf9eTeGH8JSZiwy1jq2W2mJKQEDBwqWkaEQS19Yd/MQTiw0cykxg5D7u4J6cQ=="
},
"cordova-plugin-qrscanner": {
"version": "git+https://github.com/moodlemobile/cordova-plugin-qrscanner.git#43952839ce97887d1c6cad53c7d668fe3370aedd",
"from": "git+https://github.com/moodlemobile/cordova-plugin-qrscanner.git#dist",
"requires": {
"qrcode-reader": "^1.0.4",
"webrtc-adapter": "^3.1.4"
}
},
"cordova-plugin-screen-orientation": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/cordova-plugin-screen-orientation/-/cordova-plugin-screen-orientation-3.0.2.tgz",
@ -10224,6 +10237,11 @@
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
"integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc="
},
"qrcode-reader": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/qrcode-reader/-/qrcode-reader-1.0.4.tgz",
"integrity": "sha512-rRjALGNh9zVqvweg1j5OKIQKNsw3bLC+7qwlnead5K/9cb1cEIAGkwikt/09U0K+2IDWGD9CC6SP7tHAjUeqvQ=="
},
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
@ -11127,6 +11145,11 @@
}
}
},
"sdp": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-1.5.4.tgz",
"integrity": "sha1-jgOPbdsUvXZa4fS1IW4SCUUR4NA="
},
"semver": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
@ -13864,6 +13887,14 @@
"source-map": "~0.6.1"
}
},
"webrtc-adapter": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-3.4.3.tgz",
"integrity": "sha1-tjYGLu6abvFYrNDYUBtnhDS1bxY=",
"requires": {
"sdp": "^1.5.0"
}
},
"websocket-driver": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.0.tgz",

View File

@ -66,6 +66,7 @@
"@ionic-native/media-capture": "4.20.0",
"@ionic-native/network": "4.20.0",
"@ionic-native/push": "4.20.0",
"@ionic-native/qr-scanner": "4.20.0",
"@ionic-native/screen-orientation": "4.20.0",
"@ionic-native/splash-screen": "4.20.0",
"@ionic-native/sqlite": "4.20.0",
@ -98,6 +99,7 @@
"cordova-plugin-local-notification": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle",
"cordova-plugin-media-capture": "3.0.3",
"cordova-plugin-network-information": "2.0.2",
"cordova-plugin-qrscanner": "git+https://github.com/moodlemobile/cordova-plugin-qrscanner.git#dist",
"cordova-plugin-screen-orientation": "3.0.2",
"cordova-plugin-splashscreen": "5.0.3",
"cordova-plugin-statusbar": "2.4.3",
@ -194,7 +196,8 @@
"cordova-plugin-advanced-http": {
"OKHTTP_VERSION": "3.10.0"
},
"cordova-plugin-wkwebview-cookies": {}
"cordova-plugin-wkwebview-cookies": {},
"cordova-plugin-qrscanner": {}
}
},
"main": "desktop/electron.js",
@ -254,4 +257,4 @@
"deleteAppDataOnUninstall": true
}
}
}
}

View File

@ -1333,6 +1333,8 @@
"core.browser": "local_moodlemobileapp",
"core.cancel": "moodle",
"core.cannotconnect": "local_moodlemobileapp",
"core.cannotconnecttrouble": "local_moodlemobileapp",
"core.cannotconnectverify": "local_moodlemobileapp",
"core.cannotdownloadfiles": "local_moodlemobileapp",
"core.captureaudio": "local_moodlemobileapp",
"core.capturedimage": "local_moodlemobileapp",
@ -1385,6 +1387,7 @@
"core.contentlinks.errorredirectothersite": "local_moodlemobileapp",
"core.continue": "moodle",
"core.copiedtoclipboard": "local_moodlemobileapp",
"core.copytoclipboard": "local_moodlemobileapp",
"core.course": "moodle",
"core.course.activitydisabled": "local_moodlemobileapp",
"core.course.activitynotyetviewableremoteaddon": "local_moodlemobileapp",
@ -1721,6 +1724,8 @@
"core.login.faqtestappquestion": "local_moodlemobileapp",
"core.login.faqwhatisurlanswer": "local_moodlemobileapp",
"core.login.faqwhatisurlquestion": "local_moodlemobileapp",
"core.login.faqwhereisqrcode": "local_moodlemobileapp",
"core.login.faqwhereisqrcodeanswer": "local_moodlemobileapp",
"core.login.findyoursite": "local_moodlemobileapp",
"core.login.firsttime": "moodle",
"core.login.forcepasswordchangenotice": "moodle",
@ -1750,6 +1755,7 @@
"core.login.mustconfirm": "moodle",
"core.login.newaccount": "moodle",
"core.login.notloggedin": "local_moodlemobileapp",
"core.login.or": "local_moodlemobileapp",
"core.login.password": "moodle",
"core.login.passwordforgotten": "moodle",
"core.login.passwordforgotteninstructions2": "moodle",
@ -1759,8 +1765,6 @@
"core.login.policyagreement": "moodle",
"core.login.policyagreementclick": "moodle",
"core.login.potentialidps": "auth",
"core.login.problemconnectingerror": "local_moodlemobileapp",
"core.login.problemconnectingerrorcontinue": "local_moodlemobileapp",
"core.login.profileinvaliddata": "admin",
"core.login.recaptchachallengeimage": "local_moodlemobileapp",
"core.login.recaptchaexpired": "local_moodlemobileapp",
@ -1789,6 +1793,8 @@
"core.login.usernotaddederror": "error",
"core.login.visitchangepassword": "local_moodlemobileapp",
"core.login.webservicesnotenabled": "local_moodlemobileapp",
"core.login.youcanstillconnectwithcredentials": "local_moodlemobileapp",
"core.login.yourenteredsite": "local_moodlemobileapp",
"core.lostconnection": "local_moodlemobileapp",
"core.mainmenu.changesite": "local_moodlemobileapp",
"core.mainmenu.help": "moodle",
@ -1856,6 +1862,7 @@
"core.online": "message",
"core.openfullimage": "local_moodlemobileapp",
"core.openinbrowser": "local_moodlemobileapp",
"core.openmodinbrowser": "local_moodlemobileapp",
"core.othergroups": "group",
"core.pagea": "moodle",
"core.parentlanguage": "langconfig",
@ -1866,6 +1873,7 @@
"core.previous": "moodle",
"core.proceed": "moodle",
"core.pulltorefresh": "local_moodlemobileapp",
"core.qrscanner": "local_moodlemobileapp",
"core.question.answer": "question",
"core.question.answersaved": "question",
"core.question.cannotdeterminestatus": "local_moodlemobileapp",
@ -1908,6 +1916,7 @@
"core.retry": "local_moodlemobileapp",
"core.save": "moodle",
"core.savechanges": "assign",
"core.scanqr": "local_moodlemobileapp",
"core.search": "moodle",
"core.searching": "local_moodlemobileapp",
"core.searchresults": "moodle",

View File

@ -133,8 +133,15 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo
ev && ev.stopPropagation();
if (this.assign && (this.description || this.assign.introattachments)) {
this.textUtils.expandText(this.translate.instant('core.description'), this.description, this.component,
this.module.id, this.assign.introattachments, true, 'module', this.module.id, this.courseId);
this.textUtils.viewText(this.translate.instant('core.description'), this.description, {
component: this.component,
componentId: this.module.id,
files: this.assign.introattachments,
filter: true,
contextLevel: 'module',
instanceId: this.module.id,
courseId: this.courseId,
});
}
}

View File

@ -678,8 +678,10 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
*/
showAdvancedGrade(): void {
if (this.feedback && this.feedback.advancedgrade) {
this.textUtils.expandText(this.translate.instant('core.grades.grade'), this.feedback.gradefordisplay,
AddonModAssignProvider.COMPONENT, this.moduleId);
this.textUtils.viewText(this.translate.instant('core.grades.grade'), this.feedback.gradefordisplay, {
component: AddonModAssignProvider.COMPONENT,
componentId: this.moduleId,
});
}
}

View File

@ -65,8 +65,14 @@ export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedb
if (this.text) {
// Open a new state with the text.
this.textUtils.expandText(this.plugin.name, this.text, this.component, this.assign.cmid, undefined, true,
'module', this.assign.cmid, this.assign.course);
this.textUtils.viewText(this.plugin.name, this.text, {
component: this.component,
componentId: this.assign.cmid,
filter: true,
contextLevel: 'module',
instanceId: this.assign.cmid,
courseId: this.assign.course,
});
}
});
} else if (this.edit) {

View File

@ -83,8 +83,14 @@ export class AddonModAssignSubmissionOnlineTextComponent extends AddonModAssignS
if (text) {
// Open a new state with the interpolated contents.
this.textUtils.expandText(this.plugin.name, text, this.component, this.assign.cmid, undefined, true,
'module', this.assign.cmid, this.assign.course);
this.textUtils.viewText(this.plugin.name, text, {
component: this.component,
componentId: this.assign.cmid,
filter: true,
contextLevel: 'module',
instanceId: this.assign.cmid,
courseId: this.assign.course,
});
}
});
} else {

View File

@ -14,15 +14,12 @@
import { Component, Optional, Injector, Input } from '@angular/core';
import { Content, ModalController } from 'ionic-angular';
import { CoreAppProvider } from '@providers/app';
import { CoreCourseProvider } from '@core/course/providers/course';
import {
CoreCourseModuleMainResourceComponent, CoreCourseResourceDownloadResult
} from '@core/course/classes/main-resource-component';
import {
AddonModBookProvider, AddonModBookContentsMap, AddonModBookTocChapter, AddonModBookBook, AddonModBookNavStyle
} from '../../providers/book';
import { AddonModBookPrefetchHandler } from '../../providers/prefetch-handler';
import { CoreTagProvider } from '@core/tag/providers/tag';
/**

View File

@ -22,7 +22,7 @@ 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 { CoreCustomURLSchemesProvider, CoreCustomURLSchemesHandleError } from '@providers/urlschemes';
import { CoreLoginHelperProvider } from '@core/login/providers/helper';
import { Keyboard } from '@ionic-native/keyboard';
import { ScreenOrientation } from '@ionic-native/screen-orientation';
@ -140,7 +140,9 @@ export class MoodleMobileApp implements OnInit {
if (this.urlSchemesProvider.isCustomURL(url)) {
// Close the browser if it's a valid SSO URL.
this.urlSchemesProvider.handleCustomURL(url);
this.urlSchemesProvider.handleCustomURL(url).catch((error: CoreCustomURLSchemesHandleError) => {
this.urlSchemesProvider.treatHandleCustomURLError(error);
});
this.utils.closeInAppBrowser(false);
} else if (this.platform.is('android')) {
@ -194,7 +196,9 @@ export class MoodleMobileApp implements OnInit {
this.lastUrls[url] = Date.now();
this.eventsProvider.trigger(CoreEventsProvider.APP_LAUNCHED_URL, url);
this.urlSchemesProvider.handleCustomURL(url);
this.urlSchemesProvider.handleCustomURL(url).catch((error: CoreCustomURLSchemesHandleError) => {
this.urlSchemesProvider.treatHandleCustomURLError(error);
});
});
};

View File

@ -1275,3 +1275,17 @@ ion-app.app-root {
}
}
}
// QR scan. The scanner is at the background of the app, we need to hide the elements that overlay it.
.core-scanning-qr {
ion-app.app-root {
background-color: transparent;
.ion-page {
background-color: transparent;
}
ion-content, ion-backdrop, ion-modal:not(.core-modal-fullscreen), core-ion-tabs {
display: none;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -1387,6 +1387,7 @@
"core.contentlinks.errorredirectothersite": "The redirect URL cannot point to a different site.",
"core.continue": "Continue",
"core.copiedtoclipboard": "Text copied to clipboard",
"core.copytoclipboard": "Copy to clipboard",
"core.course": "Course",
"core.course.activitydisabled": "Your organisation has disabled this activity in the mobile app.",
"core.course.activitynotyetviewableremoteaddon": "Your organisation installed a plugin that is not yet supported.",
@ -1721,8 +1722,10 @@
"core.login.faqsetupsitequestion": "I want to set up my own Moodle site.",
"core.login.faqtestappanswer": "To test the app in a Moodle Demo Site, type \"teacher\" or \"student\" in the \"Your site address\" field and click the \"Connect!\" button.",
"core.login.faqtestappquestion": "I just want to test the app, what can I do?",
"core.login.faqwhatisurlanswer": "<p>Every organisation or school has their own custom address for their Moodle site.</p><p>To find the address of the Moodle site you want to connect to, do the following:</p><ol><li>Open a web browser and go to your school's or organisation's Moodle site login page</li><li>At the top of the page, on the address bar, you will see the URL of your Moodle site. E.g. \"campus.example.edu\".{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle App and click \"Connect!\"</li><li>Now you can log into your site, using your username and password</li>",
"core.login.faqwhatisurlanswer": "<p>Every organisation or school has their own custom address for their Moodle site.</p><p>To find the address of the Moodle site you want to connect to, do the following:</p><ol><li>Open a web browser and go to your school's or organisation's Moodle site login page</li><li>At the top of the page, on the address bar, you will see the URL of your Moodle site. E.g. \"campus.example.edu\".<br>{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle App and click \"Connect!\"</li><li>Now you can log into your site, using your username and password</li>",
"core.login.faqwhatisurlquestion": "What is the URL of my Moodle site? How can I find my schools site?",
"core.login.faqwhereisqrcode": "Where can I find the QR code?",
"core.login.faqwhereisqrcodeanswer": "<p>If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.</p>{{$image}}",
"core.login.findyoursite": "Find your site",
"core.login.firsttime": "Is this your first time here?",
"core.login.forcepasswordchangenotice": "You must change your password to proceed.",
@ -1752,6 +1755,7 @@
"core.login.mustconfirm": "You need to confirm your account",
"core.login.newaccount": "New account",
"core.login.notloggedin": "You need to be logged in.",
"core.login.or": "OR",
"core.login.password": "Password",
"core.login.passwordforgotten": "Forgotten password",
"core.login.passwordforgotteninstructions2": "To reset your password, submit your username or your email address below. If we can find you in the database, an email will be sent to your email address, with instructions how to get access again.",
@ -1789,6 +1793,7 @@
"core.login.usernotaddederror": "User not added - error",
"core.login.visitchangepassword": "Do you want to visit the site to change the password?",
"core.login.webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help.",
"core.login.youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.",
"core.login.yourenteredsite": "Connect to your site",
"core.lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.",
"core.mainmenu.changesite": "Change site",
@ -1868,6 +1873,7 @@
"core.previous": "Previous",
"core.proceed": "Proceed",
"core.pulltorefresh": "Pull to refresh",
"core.qrscanner": "QR scanner",
"core.question.answer": "Answer",
"core.question.answersaved": "Answer saved",
"core.question.cannotdeterminestatus": "Cannot determine status",
@ -1910,6 +1916,7 @@
"core.retry": "Retry",
"core.save": "Save",
"core.savechanges": "Save changes",
"core.scanqr": "Scan QR code",
"core.search": "Search",
"core.searching": "Searching",
"core.searchresults": "Search results",

View File

@ -47,7 +47,13 @@ export class CoreNavigationBarComponent {
}
showInfo(): void {
this.textUtils.expandText(this.title, this.info, this.component, this.componentId, [], true, this.contextLevel,
this.contextInstanceId, this.courseId);
this.textUtils.viewText(this.title, this.info, {
component: this.component,
componentId: this.componentId,
filter: true,
contextLevel: this.contextLevel,
instanceId: this.contextInstanceId,
courseId: this.courseId,
});
}
}

View File

@ -265,8 +265,14 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
* Expand the description.
*/
expandDescription(): void {
this.textUtils.expandText(this.translate.instant('core.description'), this.description, this.component, this.module.id,
[], true, 'module', this.module.id, this.courseId);
this.textUtils.viewText(this.translate.instant('core.description'), this.description, {
component: this.component,
componentId: this.module.id,
filter: true,
contextLevel: 'module',
instanceId: this.module.id,
courseId: this.courseId,
});
}
/**

View File

@ -38,7 +38,11 @@ export class CoreCourseUnsupportedModulePage {
* Expand the description.
*/
expandDescription(): void {
this.textUtils.expandText(this.translate.instant('core.description'), this.module.description, undefined, undefined,
[], true, 'module', this.module.id, this.courseId);
this.textUtils.viewText(this.translate.instant('core.description'), this.module.description, {
filter: true,
contextLevel: 'module',
instanceId: this.module.id,
courseId: this.courseId,
});
}
}

View File

@ -1269,14 +1269,17 @@ export class CoreCourseHelperProvider {
// Get the module.
return this.courseProvider.getModule(moduleId, courseId, sectionId, false, false, siteId, modName);
}).then((module) => {
module.handlerData = this.moduleDelegate.getModuleDataFor(module.modname, module, courseId, sectionId, false);
if (navCtrl && module.handlerData && module.handlerData.action) {
if (navCtrl && this.sitesProvider.isLoggedIn() && this.sitesProvider.getCurrentSiteId() == site.getId()) {
// If the link handler for this module passed through navCtrl, we can use the module's handler to navigate cleanly.
// Otherwise, we will redirect below.
modal.dismiss();
module.handlerData = this.moduleDelegate.getModuleDataFor(module.modname, module, courseId, sectionId, false);
return module.handlerData.action(new Event('click'), navCtrl, module, courseId, undefined, modParams);
if (module.handlerData && module.handlerData.action) {
modal.dismiss();
return module.handlerData.action(new Event('click'), navCtrl, module, courseId, undefined, modParams);
}
}
this.logger.warn('navCtrl was not passed to navigateToModule by the link handler for ' + module.modname);

View File

@ -71,6 +71,11 @@
<core-icon name="fa-eraser"></core-icon>
</button>
</ion-slide>
<ion-slide *ngIf="canScanQR">
<button [disabled]="!rteEnabled" (click)="scanQR($event)" (mousedown)="stopBubble($event)">
<core-icon name="fa-qrcode"></core-icon>
</button>
</ion-slide>
<ion-slide>
<button [attr.aria-pressed]="!rteEnabled" (click)="toggleEditor($event)" (mousedown)="mouseDownAction($event)" [title]=" 'core.editor.toggle' | translate">
<core-icon name="fa-code"></core-icon>

View File

@ -95,6 +95,8 @@ export class CoreEditorRichTextEditorComponent implements AfterContentInit, OnDe
ol: 'false',
};
infoMessage: string;
canScanQR: boolean;
protected isCurrentView = true;
protected toolbarButtonWidth = 40;
protected toolbarArrowWidth = 28;
@ -119,6 +121,7 @@ export class CoreEditorRichTextEditorComponent implements AfterContentInit, OnDe
this.contentChanged = new EventEmitter<string>();
this.element = elementRef.nativeElement as HTMLDivElement;
this.pageInstance = 'app_' + Date.now(); // Generate a "unique" ID based on timestamp.
this.canScanQR = this.utils.canScanQR();
}
/**
@ -865,6 +868,24 @@ export class CoreEditorRichTextEditorComponent implements AfterContentInit, OnDe
}, timeout);
}
/**
* Scan a QR code and put its text in the editor.
*
* @param $event Event data
*/
scanQR($event: any): void {
this.stopBubble($event);
// Scan for a QR code.
this.utils.scanQR().then((text) => {
if (text) {
document.execCommand('insertText', false, text);
}
this.content.resize(); // Resize content, otherwise the content height becomes 1 for some reason.
});
}
/**
* User entered the page that contains the component.
*/

View File

@ -31,6 +31,7 @@ import { LocalNotifications } from '@ionic-native/local-notifications';
import { MediaCapture } from '@ionic-native/media-capture';
import { Network } from '@ionic-native/network';
import { Push } from '@ionic-native/push';
import { QRScanner } from '@ionic-native/qr-scanner';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
import { SQLite } from '@ionic-native/sqlite';
@ -51,12 +52,14 @@ import { LocalNotificationsMock } from './providers/local-notifications';
import { MediaCaptureMock } from './providers/media-capture';
import { NetworkMock } from './providers/network';
import { PushMock } from './providers/push';
import { QRScannerMock } from './providers/qr-scanner';
import { ZipMock } from './providers/zip';
import { CoreEmulatorHelperProvider } from './providers/helper';
import { CoreEmulatorCaptureHelperProvider } from './providers/capture-helper';
import { CoreAppProvider } from '@providers/app';
import { CoreFileProvider } from '@providers/file';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUrlUtilsProvider } from '@providers/utils/url';
@ -80,6 +83,7 @@ export const IONIC_NATIVE_PROVIDERS = [
MediaCapture,
Network,
Push,
QRScanner,
SplashScreen,
StatusBar,
SQLite,
@ -206,6 +210,13 @@ export const IONIC_NATIVE_PROVIDERS = [
return appProvider.isMobile() ? new Push() : new PushMock(appProvider);
}
},
{
provide: QRScanner,
deps: [CoreAppProvider, CoreLoggerProvider],
useFactory: (appProvider: CoreAppProvider, loggerProvider: CoreLoggerProvider): QRScanner => {
return appProvider.isMobile() ? new QRScanner() : new QRScannerMock(loggerProvider);
}
},
SplashScreen,
StatusBar,
SQLite,

View File

@ -0,0 +1,160 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { QRScanner, QRScannerStatus } from '@ionic-native/qr-scanner';
import { Observable } from 'rxjs';
import { CoreLoggerProvider } from '@providers/logger';
/**
* Emulates the Cordova QR Scanner plugin in desktop apps and in browser.
*/
@Injectable()
export class QRScannerMock extends QRScanner {
protected logger;
constructor(logger: CoreLoggerProvider) {
super();
this.logger = logger.getInstance('QRScannerMock');
}
/**
* Request permission to use QR scanner.
*
* @return Promise.
*/
prepare(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Call this method to enable scanning. You must then call the `show` method to make the camera preview visible.
*
* @return Observable that emits the scanned text. Unsubscribe from the observable to stop scanning.
*/
scan(): Observable<string> {
this.logger.error('QRScanner isn\'t available in desktop apps.');
return null;
}
/**
* Configures the native webview to have a transparent background, then sets the background of the <body> and <html> DOM
* elements to transparent, allowing the webview to re-render with the transparent background.
*
* @return Promise.
*/
show(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Configures the native webview to be opaque with a white background, covering the video preview.
*
* @return Promise.
*/
hide(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Enable the device's light (for scanning in low-light environments).
*
* @return Promise.
*/
enableLight(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Destroy the scanner instance.
*
* @return Promise.
*/
destroy(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Disable the device's light.
*
* @return Promise.
*/
disableLight(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Use front camera
*
* @return Promise.
*/
useFrontCamera(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Use back camera
*
* @return Promise.
*/
useBackCamera(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Set camera to be used.
*
* @param camera Provide `0` for back camera, and `1` for front camera.
* @return Promise.
*/
useCamera(camera: number): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Pauses the video preview on the current frame and pauses scanning.
*
* @return Promise.
*/
pausePreview(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Resumse the video preview and resumes scanning.
*
* @return Promise.
*/
resumePreview(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Returns permission status
*
* @return Promise.
*/
getStatus(): Promise<QRScannerStatus> {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
/**
* Opens settings to edit app permissions.
*/
openSettings(): void {
this.logger.error('QRScanner isn\'t available in desktop apps.');
}
}

View File

@ -33,8 +33,10 @@
"faqsetupsitequestion": "I want to set up my own Moodle site.",
"faqtestappanswer": "To test the app in a Moodle Demo Site, type \"teacher\" or \"student\" in the \"Your site address\" field and click the \"Connect!\" button.",
"faqtestappquestion": "I just want to test the app, what can I do?",
"faqwhatisurlanswer": "<p>Every organisation or school has their own custom address for their Moodle site.</p><p>To find the address of the Moodle site you want to connect to, do the following:</p><ol><li>Open a web browser and go to your school's or organisation's Moodle site login page</li><li>At the top of the page, on the address bar, you will see the URL of your Moodle site. E.g. \"campus.example.edu\".{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle App and click \"Connect!\"</li><li>Now you can log into your site, using your username and password</li>",
"faqwhatisurlanswer": "<p>Every organisation or school has their own custom address for their Moodle site.</p><p>To find the address of the Moodle site you want to connect to, do the following:</p><ol><li>Open a web browser and go to your school's or organisation's Moodle site login page</li><li>At the top of the page, on the address bar, you will see the URL of your Moodle site. E.g. \"campus.example.edu\".<br>{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle App and click \"Connect!\"</li><li>Now you can log into your site, using your username and password</li>",
"faqwhatisurlquestion": "What is the URL of my Moodle site? How can I find my schools site?",
"faqwhereisqrcode": "Where can I find the QR code?",
"faqwhereisqrcodeanswer": "<p>If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.</p>{{$image}}",
"findyoursite": "Find your site",
"firsttime": "Is this your first time here?",
"forcepasswordchangenotice": "You must change your password to proceed.",
@ -64,6 +66,7 @@
"mustconfirm": "You need to confirm your account",
"newaccount": "New account",
"notloggedin": "You need to be logged in.",
"or": "OR",
"password": "Password",
"passwordforgotten": "Forgotten password",
"passwordforgotteninstructions2": "To reset your password, submit your username or your email address below. If we can find you in the database, an email will be sent to your email address, with instructions how to get access again.",
@ -100,6 +103,7 @@
"usernamerequired": "Username required",
"usernotaddederror": "User not added - error",
"visitchangepassword": "Do you want to visit the site to change the password?",
"yourenteredsite": "Connect to your site",
"webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help."
"webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help.",
"youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.",
"yourenteredsite": "Connect to your site"
}

View File

@ -317,7 +317,7 @@ export class CoreLoginEmailSignupPage {
* Show authentication instructions.
*/
protected showAuthInstructions(): void {
this.textUtils.expandText(this.translate.instant('core.login.instructions'), this.authInstructions);
this.textUtils.viewText(this.translate.instant('core.login.instructions'), this.authInstructions);
}
/**

View File

@ -14,7 +14,7 @@
<ion-item text-wrap>
<h2><b>{{ 'core.login.faqwhatisurlquestion' | translate }}</b></h2>
</ion-item>
<ion-item text-wrap>
<ion-item text-wrap class="core-login-faqwhatisurlanswer">
<div [innerHTML]="'core.login.faqwhatisurlanswer' | translate: {$image: urlImageHtml}">
</div>
</ion-item>
@ -36,5 +36,12 @@
<ion-item text-wrap>
<p>{{ 'core.login.faqtestappanswer' | translate }}</p>
</ion-item>
<ion-item text-wrap *ngIf="canScanQR">
<h2><b>{{ 'core.login.faqwhereisqrcode' | translate }}</b></h2>
</ion-item>
<ion-item text-wrap *ngIf="canScanQR" class="core-login-faqwhereisqrcodeanswer">
<div [innerHTML]="'core.login.faqwhereisqrcodeanswer' | translate: {$image: qrCodeImageHtml}">
</div>
</ion-item>
</ion-list>
</ion-content>

View File

@ -2,4 +2,14 @@ page-core-login-site-help {
.content {
background-color: $white;
}
.core-login-faqwhatisurlanswer img {
max-height: 50px;
}
.core-login-faqwhereisqrcodeanswer img {
max-height: 220px;
margin-top: 5px;
margin-bottom: 5px;
}
}

View File

@ -15,6 +15,8 @@
import { Component } from '@angular/core';
import { IonicPage, ViewController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreLoginHelperProvider } from '../../providers/helper';
/**
* Component that displays some help regarding the CoreLoginSitePage.
@ -28,11 +30,16 @@ export class CoreLoginSiteHelpPage {
urlImageHtml: string;
setupLinkHtml: string;
qrCodeImageHtml: string;
canScanQR: boolean;
constructor(protected viewCtrl: ViewController,
protected translate: TranslateService) {
protected translate: TranslateService,
protected utils: CoreUtilsProvider) {
this.urlImageHtml = '<img src="assets/img/login/faq_url.png" role="presentation">';
this.canScanQR = this.utils.canScanQR() && false; // @todo: Enable it for 3.9 release.
this.urlImageHtml = CoreLoginHelperProvider.FAQ_URL_IMAGE_HTML;
this.qrCodeImageHtml = CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML;
this.setupLinkHtml = '<a href="https://moodle.com/getstarted/" title="' +
this.translate.instant('core.login.faqsetupsitelinktitle') + '">https://moodle.com/getstarted/</a>';
}

View File

@ -47,6 +47,17 @@
<ion-option *ngFor="let site of fixedSites" [value]="site.url">{{site.name}}</ion-option>
</ion-select>
</ion-item>
<ng-container *ngIf="!fixedSites && showScanQR && !hasSites">
<div class="core-login-site-qrcode-separator">{{ 'core.login.or' | translate }}</div>
<ion-item>
<a ion-button block color="light" margin-top icon-start (click)="scanQR()">
<core-icon name="fa-qrcode" aria-hidden="true"></core-icon>
{{ 'core.scanqr' | translate }}
</a>
</ion-item>
</ng-container>
</form>
<!-- Pick the site from a list of fixed sites. -->

View File

@ -115,4 +115,10 @@ ion-app.app-root page-core-login-site {
.core-login-default-icon {
filter: grayscale(100%);
}
.core-login-site-qrcode-separator {
text-align: center;
margin-top: 12px;
font-size: 1.2em;
}
}

View File

@ -17,9 +17,11 @@ import { IonicPage, NavController, ModalController, AlertController, NavParams }
import { CoreAppProvider } from '@providers/app';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider, CoreSiteCheckResponse, CoreLoginSiteInfo } from '@providers/sites';
import { CoreCustomURLSchemesProvider, CoreCustomURLSchemesHandleError } from '@providers/urlschemes';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUrlUtilsProvider } from '@providers/utils/url';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreConfigConstants } from '../../../../configconstants';
import { CoreLoginHelperProvider } from '../../providers/helper';
import { FormBuilder, FormGroup, ValidatorFn, AbstractControl } from '@angular/forms';
@ -58,6 +60,7 @@ export class CoreLoginSitePage {
loadingSites = false;
onlyWrittenSite = false;
searchFnc: Function;
showScanQR: boolean;
constructor(navParams: NavParams,
protected navCtrl: NavController,
@ -71,9 +74,12 @@ export class CoreLoginSitePage {
protected domUtils: CoreDomUtilsProvider,
protected eventsProvider: CoreEventsProvider,
protected translate: TranslateService,
protected utils: CoreUtilsProvider) {
protected utils: CoreUtilsProvider,
protected urlSchemesProvider: CoreCustomURLSchemesProvider,
protected textUtils: CoreTextUtilsProvider) {
this.showKeyboard = !!navParams.get('showKeyboard');
this.showScanQR = this.utils.canScanQR();
let url = '';
@ -231,7 +237,7 @@ export class CoreLoginSitePage {
* Show a help modal.
*/
showHelp(): void {
const modal = this.modalCtrl.create('CoreLoginSiteHelpPage');
const modal = this.modalCtrl.create('CoreLoginSiteHelpPage', {}, { cssClass: 'core-modal-fullscreen' });
modal.present();
}
@ -340,20 +346,115 @@ export class CoreLoginSitePage {
* @return {ValidatorFn} Validation results.
*/
protected moodleUrlValidator(): ValidatorFn {
return (control: AbstractControl): {[key: string]: any} | null => {
const value = control.value.trim();
let valid = value.length >= 3 && CoreUrl.isValidMoodleUrl(value);
return (control: AbstractControl): {[key: string]: any} | null => {
const value = control.value.trim();
let valid = value.length >= 3 && CoreUrl.isValidMoodleUrl(value);
if (!valid) {
const demo = !!this.getDemoSiteData(value);
if (!valid) {
const demo = !!this.getDemoSiteData(value);
if (demo) {
valid = true;
if (demo) {
valid = true;
}
}
}
return valid ? null : {siteUrl: {value: control.value}};
};
return valid ? null : {siteUrl: {value: control.value}};
};
}
/**
* Show instructions and scan QR code.
*
* @todo Use it in 3.9 release instead of scanQR.
*/
showInstructionsAndScanQR(): void {
// Show some instructions first.
this.domUtils.showAlertWithButtons(
this.translate.instant('core.login.faqwhereisqrcode'),
this.translate.instant('core.login.faqwhereisqrcodeanswer', {$image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML}),
[
{
text: this.translate.instant('core.cancel'),
role: 'cancel'
},
{
text: this.translate.instant('core.next'),
handler: (): void => {
this.scanQR();
}
},
]
);
}
/**
* Scan a QR code and put its text in the URL input.
*
* @return Promise resolved when done.
*/
async scanQR(): Promise<void> {
// Scan for a QR code.
const text = await this.utils.scanQR();
if (text) {
if (this.urlSchemesProvider.isCustomURL(text)) {
try {
await this.urlSchemesProvider.handleCustomURL(text);
} catch (error) {
if (error && error.data && error.data.isAuthenticationURL && error.data.siteUrl) {
// An error ocurred, but it's an authentication URL and we have the site URL.
this.treatErrorInAuthenticationCustomURL(text, error);
} else {
this.urlSchemesProvider.treatHandleCustomURLError(error);
}
}
} else {
// Not a custom URL scheme, put the text in the field.
this.siteForm.controls.siteUrl.setValue(text);
this.connect(new Event('click'), text);
}
}
}
/**
* Treat an error while handling a custom URL meant to perform an authentication.
* If the site doesn't use SSO, the user will be sent to the credentials screen.
*
* @param customURL Custom URL handled.
* @param error Error data.
* @return Promise resolved when done.
*/
protected async treatErrorInAuthenticationCustomURL(customURL: string, error: CoreCustomURLSchemesHandleError): Promise<void> {
const siteUrl = error.data.siteUrl;
const modal = this.domUtils.showModalLoading();
// Set the site URL in the input.
this.siteForm.controls.siteUrl.setValue(siteUrl);
try {
// Check if site uses SSO.
const response = await this.sitesProvider.checkSite(siteUrl);
await this.sitesProvider.checkRequiredMinimumVersion(response.config);
if (!this.loginHelper.isSSOLoginNeeded(response.code)) {
// No SSO, go to credentials page.
await this.navCtrl.push('CoreLoginCredentialsPage', {
siteUrl: response.siteUrl,
siteConfig: response.config,
});
}
} catch (error) {
// Ignore errors.
} finally {
modal.dismiss();
}
// Now display the error.
error.error = this.textUtils.addTextToError(error.error,
'<br><br>' + this.translate.instant('core.login.youcanstillconnectwithcredentials'));
this.urlSchemesProvider.treatHandleCustomURLError(error);
}
}

View File

@ -75,6 +75,8 @@ export interface CoreLoginSSOData {
@Injectable()
export class CoreLoginHelperProvider {
static OPEN_COURSE = 'open_course';
static FAQ_URL_IMAGE_HTML = '<img src="assets/img/login/faq_url.png" role="presentation">';
static FAQ_QRCODE_IMAGE_HTML = '<img src="assets/img/login/faq_qrcode.png" role="presentation">';
protected logger;
protected isSSOConfirmShown = false;

View File

@ -31,6 +31,10 @@
<h2>{{item.label}}</h2>
</a>
</div>
<a ion-item *ngIf="showScanQR" (click)="scanQR()">
<core-icon name="fa-qrcode" item-start aria-hidden="true"></core-icon>
<h2>{{ 'core.scanqr' | translate }}</h2>
</a>
<a *ngIf="showWeb" ion-item [href]="siteInfo.siteurl" core-link autoLogin="yes" title="{{ 'core.mainmenu.website' | translate }}">
<ion-icon name="globe" item-start aria-hidden="true"></ion-icon>
<h2>{{ 'core.mainmenu.website' | translate }}</h2>

View File

@ -16,9 +16,14 @@ import { Component, OnDestroy } from '@angular/core';
import { IonicPage, NavController } from 'ionic-angular';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import { CoreCustomURLSchemesProvider, CoreCustomURLSchemesHandleError } from '@providers/urlschemes';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../providers/delegate';
import { CoreMainMenuProvider, CoreMainMenuCustomItem } from '../../providers/mainmenu';
import { CoreLoginHelperProvider } from '@core/login/providers/helper';
import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
import { TranslateService } from '@ngx-translate/core';
/**
* Page that displays the list of main menu options that aren't in the tabs.
@ -35,6 +40,7 @@ export class CoreMainMenuMorePage implements OnDestroy {
siteInfo: any;
siteName: string;
logoutLabel: string;
showScanQR: boolean;
showWeb: boolean;
showHelp: boolean;
docsUrl: string;
@ -45,14 +51,23 @@ export class CoreMainMenuMorePage implements OnDestroy {
protected langObserver;
protected updateSiteObserver;
constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider,
private navCtrl: NavController, private mainMenuProvider: CoreMainMenuProvider,
eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider) {
constructor(protected menuDelegate: CoreMainMenuDelegate,
protected sitesProvider: CoreSitesProvider,
protected navCtrl: NavController,
protected mainMenuProvider: CoreMainMenuProvider,
eventsProvider: CoreEventsProvider,
protected loginHelper: CoreLoginHelperProvider,
protected utils: CoreUtilsProvider,
protected linkHelper: CoreContentLinksHelperProvider,
protected textUtils: CoreTextUtilsProvider,
protected urlSchemesProvider: CoreCustomURLSchemesProvider,
protected translate: TranslateService) {
this.langObserver = eventsProvider.on(CoreEventsProvider.LANGUAGE_CHANGED, this.loadSiteInfo.bind(this));
this.updateSiteObserver = eventsProvider.on(CoreEventsProvider.SITE_UPDATED, this.loadSiteInfo.bind(this),
sitesProvider.getCurrentSiteId());
this.loadSiteInfo();
this.showScanQR = this.utils.canScanQR();
}
/**
@ -155,6 +170,36 @@ export class CoreMainMenuMorePage implements OnDestroy {
this.navCtrl.push('CoreSitePreferencesPage');
}
/**
* Scan and treat a QR code.
*/
scanQR(): void {
// Scan for a QR code.
this.utils.scanQR().then((text) => {
if (text) {
if (this.urlSchemesProvider.isCustomURL(text)) {
// Is a custom URL scheme, handle it.
this.urlSchemesProvider.handleCustomURL(text).catch((error: CoreCustomURLSchemesHandleError) => {
this.urlSchemesProvider.treatHandleCustomURLError(error);
});
} else if (/^[^:]{2,}:\/\/[^ ]+$/i.test(text)) { // Check if it's a URL.
// Check if the app can handle the URL.
this.linkHelper.handleLink(text, undefined, this.navCtrl, true, true).then((treated) => {
if (!treated) {
// Can't handle it, open it in browser.
this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(text);
}
});
} else {
// It's not a URL, open it in a modal so the user can see it and copy it.
this.textUtils.viewText(this.translate.instant('core.qrscanner'), text, {
displayCopyButton: true,
});
}
}
});
}
/**
* Logout the user.
*/

View File

@ -709,8 +709,14 @@ export class CoreQuestionHelperProvider {
if (span) {
// There's a hidden feedback, show it when the icon is clicked.
icon.addEventListener('click', (event) => {
this.textUtils.expandText(title, span.innerHTML, component, componentId, [], true, contextLevel,
contextInstanceId, courseId);
this.textUtils.viewText(title, span.innerHTML, {
component: component,
componentId: componentId,
filter: true,
contextLevel: contextLevel,
instanceId: contextInstanceId,
courseId: courseId,
});
});
}
});

View File

@ -146,8 +146,14 @@ export class CoreSitePluginsModuleIndexComponent implements OnInit, OnDestroy, C
* Expand the description.
*/
expandDescription(): void {
this.textUtils.expandText(this.translate.instant('core.description'), this.description, this.component, this.module.id,
[], true, 'module', this.module.id, this.courseId);
this.textUtils.viewText(this.translate.instant('core.description'), this.description, {
component: this.component,
componentId: this.module.id,
filter: true,
contextLevel: 'module',
instanceId: this.module.id,
courseId: this.courseId,
});
}
/**

View File

@ -0,0 +1,13 @@
<ion-header>
<ion-navbar core-back-button>
<ion-title>{{ title }}</ion-title>
<ion-buttons end>
<button ion-button icon-only (click)="cancel()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
</ion-content>

View File

@ -0,0 +1,31 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreViewerQRScannerPage } from './qr-scanner';
import { CoreDirectivesModule } from '@directives/directives.module';
@NgModule({
declarations: [
CoreViewerQRScannerPage
],
imports: [
CoreDirectivesModule,
IonicPageModule.forChild(CoreViewerQRScannerPage),
TranslateModule.forChild()
]
})
export class CoreViewerQRScannerPageModule {}

View File

@ -0,0 +1,79 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { IonicPage, ViewController, NavParams } from 'ionic-angular';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
/**
* Page to scan a QR code.
*/
@IonicPage({ segment: 'core-viewer-qr-scanner' })
@Component({
selector: 'page-core-viewer-qr-scanner',
templateUrl: 'qr-scanner.html',
})
export class CoreViewerQRScannerPage {
title: string; // Page title.
constructor(params: NavParams,
translate: TranslateService,
protected viewCtrl: ViewController,
protected domUtils: CoreDomUtilsProvider,
protected utils: CoreUtilsProvider) {
this.title = params.get('title') || translate.instant('core.scanqr');
this.utils.startScanQR().then((text) => {
// Text captured, return it.
text = typeof text == 'string' ? text.trim() : '';
this.closeModal(text);
}).catch((error) => {
if (!error.coreCanceled) {
// Show error and stop scanning.
this.domUtils.showErrorModalDefault(error, 'An error occurred.');
this.utils.stopScanQR();
}
this.closeModal();
});
}
/**
* Cancel scanning.
*/
cancel(): void {
this.utils.stopScanQR();
}
/**
* Close modal.
*
* @param text The text to return (if any).
*/
closeModal(text?: string): void {
this.viewCtrl.dismiss(text);
}
/**
* View will leave.
*/
ionViewWillLeave(): void {
// If this code is reached and scan hasn't been stopped yet it means the user clicked the back button, cancel.
this.utils.stopScanQR();
}
}

View File

@ -16,3 +16,9 @@
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="componentId"></core-file>
</ion-card>
</ion-content>
<ion-footer color="light" *ngIf="displayCopyButton">
<button ion-button block color="light" icon-start (click)="copyText()">
<ion-icon name="copy" aria-hidden="true"></ion-icon>
{{ 'core.copytoclipboard' | translate }}
</button>
</ion-footer>

View File

@ -0,0 +1,5 @@
ion-app.app-root page-core-viewer-text {
ion-footer {
padding: 6px;
}
}

View File

@ -15,6 +15,7 @@
import { Component } from '@angular/core';
import { IonicPage, ViewController, NavParams } from 'ionic-angular';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUtils } from '@providers/utils/utils';
/**
* Page to render a certain text. If opened as a modal, it will have a button to close the modal.
@ -34,6 +35,7 @@ export class CoreViewerTextPage {
contextLevel: string; // The context level.
instanceId: number; // The instance ID related to the context.
courseId: number; // Course ID the text belongs to. It can be used to improve performance with filters.
displayCopyButton: boolean; // Whether to display a button to copy the contents.
constructor(private viewCtrl: ViewController, params: NavParams, textUtils: CoreTextUtilsProvider) {
this.title = params.get('title');
@ -45,6 +47,7 @@ export class CoreViewerTextPage {
this.contextLevel = params.get('contextLevel');
this.instanceId = params.get('instanceId');
this.courseId = params.get('courseId');
this.displayCopyButton = !!params.get('displayCopyButton');
}
/**
@ -53,4 +56,11 @@ export class CoreViewerTextPage {
closeModal(): void {
this.viewCtrl.dismiss();
}
/**
* Copy the text to clipboard.
*/
copyText(): void {
CoreUtils.instance.copyToClipboard(this.content);
}
}

View File

@ -35,6 +35,7 @@ import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreFilterProvider, CoreFilterFilter, CoreFilterFormatTextOptions } from '@core/filter/providers/filter';
import { CoreFilterHelperProvider } from '@core/filter/providers/helper';
import { CoreFilterDelegate } from '@core/filter/providers/delegate';
import { CoreCustomURLSchemesProvider } from '@providers/urlschemes';
/**
* Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective
@ -87,13 +88,14 @@ export class CoreFormatTextDirective implements OnChanges {
protected contentLinksHelper: CoreContentLinksHelperProvider,
@Optional() protected navCtrl: NavController,
@Optional() protected content: Content, @Optional()
protected svComponent: CoreSplitViewComponent,
@Optional() protected svComponent: CoreSplitViewComponent,
protected iframeUtils: CoreIframeUtilsProvider,
protected eventsProvider: CoreEventsProvider,
protected filterProvider: CoreFilterProvider,
protected filterHelper: CoreFilterHelperProvider,
protected filterDelegate: CoreFilterDelegate,
protected viewContainerRef: ViewContainerRef,
protected urlSchemesProvider: CoreCustomURLSchemesProvider
) {
this.element = element.nativeElement;
@ -305,8 +307,14 @@ export class CoreFormatTextDirective implements OnChanges {
// Open a new state with the contents.
const filter = typeof this.filter != 'undefined' ? this.utils.isTrueOrOne(this.filter) : undefined;
this.textUtils.expandText(this.fullTitle || this.translate.instant('core.description'), this.text,
this.component, this.componentId, undefined, filter, this.contextLevel, this.contextInstanceId, this.courseId);
this.textUtils.viewText(this.fullTitle || this.translate.instant('core.description'), this.text, {
component: this.component,
componentId: this.componentId,
filter: filter,
contextLevel: this.contextLevel,
instanceId: this.contextInstanceId,
courseId: this.courseId,
});
}
}
@ -467,7 +475,7 @@ export class CoreFormatTextDirective implements OnChanges {
anchors.forEach((anchor) => {
// Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually.
const linkDir = new CoreLinkDirective(anchor, this.domUtils, this.utils, this.sitesProvider, this.urlUtils,
this.contentLinksHelper, this.navCtrl, this.content, this.svComponent, this.textUtils);
this.contentLinksHelper, this.navCtrl, this.content, this.svComponent, this.textUtils, this.urlSchemesProvider);
linkDir.capture = true;
linkDir.ngOnInit();

View File

@ -19,9 +19,9 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUrlUtilsProvider } from '@providers/utils/url';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
import { CoreConfigConstants } from '../configconstants';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreCustomURLSchemesProvider, CoreCustomURLSchemesHandleError } from '@providers/urlschemes';
/**
* Directive to open a link in external browser.
@ -39,11 +39,17 @@ export class CoreLinkDirective implements OnInit {
protected element: HTMLElement;
constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider,
private sitesProvider: CoreSitesProvider, private urlUtils: CoreUrlUtilsProvider,
private contentLinksHelper: CoreContentLinksHelperProvider, @Optional() private navCtrl: NavController,
@Optional() private content: Content, @Optional() private svComponent: CoreSplitViewComponent,
private textUtils: CoreTextUtilsProvider) {
constructor(element: ElementRef,
protected domUtils: CoreDomUtilsProvider,
protected utils: CoreUtilsProvider,
protected sitesProvider: CoreSitesProvider,
protected urlUtils: CoreUrlUtilsProvider,
protected contentLinksHelper: CoreContentLinksHelperProvider,
@Optional() protected navCtrl: NavController,
@Optional() protected content: Content,
@Optional() protected svComponent: CoreSplitViewComponent,
protected textUtils: CoreTextUtilsProvider,
protected urlSchemesProvider: CoreCustomURLSchemesProvider) {
// This directive can be added dynamically. In that case, the first param is the anchor HTMLElement.
this.element = element.nativeElement || element;
}
@ -90,7 +96,6 @@ export class CoreLinkDirective implements OnInit {
* @param href HREF to be opened.
*/
protected navigate(href: string): void {
const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link=';
if (this.urlUtils.isLocalFileUrl(href)) {
// We have a local file.
@ -107,10 +112,10 @@ export class CoreLinkDirective implements OnInit {
// Look for id or name.
this.domUtils.scrollToElementBySelector(this.content, '#' + href + ', [name=\'' + href + '\']');
}
} else if (href.indexOf(contentLinksScheme) === 0) {
// Link should be treated by Custom URL Scheme. Encode the right part, otherwise ':' is removed in iOS.
href = contentLinksScheme + encodeURIComponent(href.replace(contentLinksScheme, ''));
this.utils.openInBrowser(href);
} else if (this.urlSchemesProvider.isCustomURL(href)) {
this.urlSchemesProvider.handleCustomURL(href).catch((error: CoreCustomURLSchemesHandleError) => {
this.urlSchemesProvider.treatHandleCustomURLError(error);
});
} else {
// It's an external link, we will open with browser. Check if we need to auto-login.

View File

@ -51,6 +51,7 @@
"contenteditingsynced": "The content you are editing has been synced.",
"continue": "Continue",
"copiedtoclipboard": "Text copied to clipboard",
"copytoclipboard": "Copy to clipboard",
"course": "Course",
"coursedetails": "Course details",
"coursenogroups": "You are not a member of any group of this course.",
@ -209,6 +210,7 @@
"previous": "Previous",
"proceed": "Proceed",
"pulltorefresh": "Pull to refresh",
"qrscanner": "QR scanner",
"quotausage": "You have currently used {{$a.used}} of your {{$a.total}} limit.",
"redirectingtosite": "You will be redirected to the site.",
"refresh": "Refresh",
@ -223,6 +225,7 @@
"retry": "Retry",
"save": "Save",
"savechanges": "Save changes",
"scanqr": "Scan QR code",
"search": "Search",
"searching": "Searching",
"searchresults": "Search results",

View File

@ -17,7 +17,7 @@ import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from './app';
import { CoreInitDelegate } from './init';
import { CoreLoggerProvider } from './logger';
import { CoreSitesProvider } from './sites';
import { CoreSitesProvider, CoreSiteCheckResponse } from './sites';
import { CoreDomUtilsProvider } from './utils/dom';
import { CoreTextUtilsProvider } from './utils/text';
import { CoreUrlUtilsProvider } from './utils/url';
@ -44,6 +44,16 @@ export interface CoreCustomURLSchemesParams extends CoreLoginSSOData {
* URL to open once authenticated.
*/
redirect?: any;
/**
* Whether it's an SSO token URL.
*/
isSSOToken?: boolean;
/**
* Whether the URL is meant to perform an authentication.
*/
isAuthenticationURL?: boolean;
}
/*
@ -54,30 +64,66 @@ export class CoreCustomURLSchemesProvider {
protected logger;
protected lastUrls = {};
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) {
constructor(logger: CoreLoggerProvider,
protected appProvider: CoreAppProvider,
protected utils: CoreUtilsProvider,
protected loginHelper: CoreLoginHelperProvider,
protected linksHelper: CoreContentLinksHelperProvider,
protected initDelegate: CoreInitDelegate,
protected domUtils: CoreDomUtilsProvider,
protected urlUtils: CoreUrlUtilsProvider,
protected sitesProvider: CoreSitesProvider,
protected textUtils: CoreTextUtilsProvider,
protected linksDelegate: CoreContentLinksDelegate,
protected translate: TranslateService,
protected sitePluginsProvider: CoreSitePluginsProvider) {
this.logger = logger.getInstance('CoreCustomURLSchemesProvider');
}
/**
* Given some data of a custom URL with a token, create a site if it needs to be created.
*
* @param data URL data.
* @return Promise resolved with the site ID.
*/
protected async createSiteIfNeeded(data: CoreCustomURLSchemesParams): Promise<string> {
if (!data.token) {
return;
}
const currentSite = this.sitesProvider.getCurrentSite();
if (!currentSite || currentSite.getToken() != data.token) {
// Token belongs to a different site, create it. It doesn't matter if it already exists.
if (!data.siteUrl.match(/^https?:\/\//)) {
// URL doesn't have a protocol and it's required to be able to create the site. Check which one to use.
const result = await this.sitesProvider.checkSite(data.siteUrl);
data.siteUrl = result.siteUrl;
await this.sitesProvider.checkRequiredMinimumVersion(result.config);
}
return this.sitesProvider.newSite(data.siteUrl, data.token, data.privateToken, data.isSSOToken,
this.loginHelper.getOAuthIdFromParams(data.ssoUrlParams));
} else {
// Token belongs to current site, no need to create it.
return this.sitesProvider.getCurrentSiteId();
}
}
/**
* Handle an URL received by custom URL scheme.
*
* @param url URL to treat.
* @return Promise resolved when done.
* @return Promise resolved when done. If rejected, the parameter is of type CoreCustomURLSchemesHandleError.
*/
handleCustomURL(url: string): Promise<any> {
async handleCustomURL(url: string): Promise<void> {
if (!this.isCustomURL(url)) {
return Promise.reject(null);
throw new CoreCustomURLSchemesHandleError(null);
}
let modal,
isSSOToken = false,
data: CoreCustomURLSchemesParams;
/* First check that this URL hasn't been treated a few seconds ago. The function that handles custom URL schemes already
does this, but this function is called from other places so we need to handle it in here too. */
if (this.lastUrls[url] && Date.now() - this.lastUrls[url] < 3000) {
@ -86,69 +132,49 @@ export class CoreCustomURLSchemesProvider {
}
this.lastUrls[url] = Date.now();
url = this.textUtils.decodeURIComponent(url);
// Wait for app to be ready.
return this.initDelegate.ready().then(() => {
url = this.textUtils.decodeURIComponent(url);
await this.initDelegate.ready();
// 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(/\/?#?\/?$/, '');
// 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();
const modal = this.domUtils.showModalLoading();
let data: CoreCustomURLSchemesParams;
// Get the data from the URL.
// Get the data from the URL.
try {
if (this.isCustomURLToken(url)) {
isSSOToken = true;
return this.getCustomURLTokenData(url);
data = await this.getCustomURLTokenData(url);
} else if (this.isCustomURLLink(url)) {
// In iOS, the protocol after the scheme doesn't have ":". Add it.
url = url.replace(/\/\/link=(https?)\/\//, '//link=$1://');
return this.getCustomURLLinkData(url);
data = await this.getCustomURLLinkData(url);
} else {
// In iOS, the protocol after the scheme doesn't have ":". Add it.
url = url.replace(/\/\/(https?)\/\//, '//$1://');
return this.getCustomURLData(url);
data = await this.getCustomURLData(url);
}
}).then((result) => {
data = result;
} catch (error) {
modal.dismiss();
throw error;
}
try {
if (data.redirect && data.redirect.match(/^https?:\/\//) && data.redirect.indexOf(data.siteUrl) == -1) {
// Redirect URL must belong to the same site. Reject.
return Promise.reject(this.translate.instant('core.contentlinks.errorredirectothersite'));
throw this.translate.instant('core.contentlinks.errorredirectothersite');
}
// First of all, authenticate the user if needed.
const currentSite = this.sitesProvider.getCurrentSite();
// First of all, create the site if needed.
const siteId = await this.createSiteIfNeeded(data);
if (data.token) {
if (!currentSite || currentSite.getToken() != data.token) {
// Token belongs to a different site, create it. It doesn't matter if it already exists.
let promise;
if (!data.siteUrl.match(/^https?:\/\//)) {
// URL doesn't have a protocol and it's required to be able to create the site. Check which one to use.
promise = this.sitesProvider.checkSite(data.siteUrl).then((result) => {
data.siteUrl = result.siteUrl;
});
} else {
promise = Promise.resolve();
}
return promise.then(() => {
return this.sitesProvider.newSite(data.siteUrl, data.token, data.privateToken, isSSOToken,
this.loginHelper.getOAuthIdFromParams(data.ssoUrlParams));
});
} else {
// Token belongs to current site, no need to create it.
return this.sitesProvider.getCurrentSiteId();
}
}
}).then((siteId) => {
if (isSSOToken) {
if (data.isSSOToken) {
// Site created and authenticated, open the page to go.
if (data.pageName) {
// State defined, go to that state instead of site initial page.
@ -165,113 +191,58 @@ export class CoreCustomURLSchemesProvider {
data.redirect = this.textUtils.concatenatePaths(data.siteUrl, data.redirect);
}
let promise;
let siteIds = [siteId];
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);
if (!siteId) {
// No site created, check if the site is stored (to know which one to use).
siteIds = await 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);
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 if (siteIds.length == 1) {
// Only one site, handle the link.
const site = await this.sitesProvider.getSite(siteIds[0]);
if (!data.redirect) {
// No redirect, go to the root URL if needed.
await this.linksHelper.handleRootURL(site, false, 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;
// Handle the redirect link.
modal.dismiss(); // Dismiss modal so it doesn't collide with confirms.
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;
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);
}
const treated = await this.linksHelper.handleLink(data.redirect, username);
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);
}
});
});
if (!treated) {
this.domUtils.showErrorModal('core.contentlinks.errornoactions', true);
}
}
});
}).catch((error) => {
if (error == 'Duplicated') {
// Duplicated request
} else 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'));
// Site not stored. Try to add the site.
const result = await this.sitesProvider.checkSite(data.siteUrl);
// Site exists. We'll allow to add it.
modal.dismiss(); // Dismiss modal so it doesn't collide with confirms.
await this.goToAddSite(data, result);
}
}).finally(() => {
} catch (error) {
throw new CoreCustomURLSchemesHandleError(error, data);
} finally {
modal.dismiss();
if (isSSOToken) {
if (data.isSSOToken) {
this.appProvider.finishSSOAuthentication();
}
});
}
}
/**
@ -281,17 +252,16 @@ export class CoreCustomURLSchemesProvider {
* @param url URL to treat.
* @return Promise resolved with the data.
*/
protected getCustomURLData(url: string): Promise<CoreCustomURLSchemesParams> {
const urlScheme = CoreConfigConstants.customurlscheme + '://';
if (url.indexOf(urlScheme) == -1) {
return Promise.reject(null);
protected async getCustomURLData(url: string): Promise<CoreCustomURLSchemesParams> {
if (!this.isCustomURL(url)) {
throw new CoreCustomURLSchemesHandleError(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, '');
url = this.removeCustomURLScheme(url);
// Detect if there's a user specified.
const username = this.urlUtils.getUsernameFromUrl(url);
@ -307,34 +277,26 @@ export class CoreCustomURLSchemesProvider {
url = url.substr(0, url.indexOf('?'));
}
let promise;
if (!url.match(/https?:\/\//)) {
// Url doesn't have a protocol. Check if the site is stored in the app to be able to determine the protocol.
promise = this.sitesProvider.getSiteIdsFromUrl(url, true, username).then((siteIds) => {
if (siteIds.length) {
// There is at least 1 site with this URL. Use it to know the full URL.
return this.sitesProvider.getSite(siteIds[0]).then((site) => {
return site.getURL();
});
} else {
// No site stored with this URL, just use the URL as it is.
return url;
}
});
} else {
promise = Promise.resolve(url);
const siteIds = await this.sitesProvider.getSiteIdsFromUrl(url, true, username);
if (siteIds.length) {
// There is at least 1 site with this URL. Use it to know the full URL.
const site = await this.sitesProvider.getSite(siteIds[0]);
url = site.getURL();
}
}
return promise.then((url) => {
return {
siteUrl: url,
username: username,
token: params.token,
privateToken: params.privateToken,
redirect: params.redirect
};
});
return {
siteUrl: url,
username: username,
token: params.token,
privateToken: params.privateToken,
redirect: params.redirect,
isAuthenticationURL: !!params.token,
};
}
/**
@ -343,17 +305,16 @@ export class CoreCustomURLSchemesProvider {
* @param url URL to treat.
* @return 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);
protected async getCustomURLLinkData(url: string): Promise<CoreCustomURLSchemesParams> {
if (!this.isCustomURLLink(url)) {
throw new CoreCustomURLSchemesHandleError(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, '');
url = this.removeCustomURLLinkScheme(url);
// Detect if there's a user specified.
const username = this.urlUtils.getUsernameFromUrl(url);
@ -362,43 +323,42 @@ export class CoreCustomURLSchemesProvider {
}
// First of all, check if it's the root URL of a site.
return this.sitesProvider.isStoredRootURL(url, username).then((data): any => {
const data = await this.sitesProvider.isStoredRootURL(url, username);
if (data.site) {
// Root URL.
return {
siteUrl: data.site.getURL(),
username: username
};
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 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.
const site = await this.sitesProvider.getSite(data.siteIds[0]);
} else {
// Get the site URL.
let siteUrl = this.linksDelegate.getSiteUrl(url),
redirect = url;
return {
siteUrl: site.getURL(),
username: username,
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;
}
} else {
// Get the site URL.
let siteUrl = this.linksDelegate.getSiteUrl(url);
let redirect = url;
return {
siteUrl: siteUrl,
username: username,
redirect: redirect
};
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
};
}
}
/**
@ -407,15 +367,14 @@ export class CoreCustomURLSchemesProvider {
* @param url URL to treat.
* @return 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);
protected async getCustomURLTokenData(url: string): Promise<CoreCustomURLSchemesParams> {
if (!this.isCustomURLToken(url)) {
throw new CoreCustomURLSchemesHandleError(null);
}
if (this.appProvider.isSSOAuthenticationOngoing()) {
// Authentication ongoing, probably duplicated request.
return Promise.reject('Duplicated');
throw new CoreCustomURLSchemesHandleError('Duplicated');
}
if (this.appProvider.isDesktop()) {
@ -428,7 +387,7 @@ export class CoreCustomURLSchemesProvider {
this.logger.debug('App launched by URL with an SSO');
// Delete the sso scheme from the URL.
url = url.replace(ssoScheme, '');
url = this.removeCustomURLTokenScheme(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.
@ -441,10 +400,56 @@ export class CoreCustomURLSchemesProvider {
// Error decoding the parameter.
this.logger.error('Error decoding parameter received for login SSO');
return Promise.reject(null);
throw new CoreCustomURLSchemesHandleError(null);
}
return this.loginHelper.validateBrowserSSOLogin(url);
const data: CoreCustomURLSchemesParams = await this.loginHelper.validateBrowserSSOLogin(url);
data.isSSOToken = true;
data.isAuthenticationURL = true;
return data;
}
/**
* Go to page to add a site, or open a browser if SSO.
*
* @param data URL data.
* @param checkResponse Result of checkSite.
* @return Promise resolved when done.
*/
protected async goToAddSite(data: CoreCustomURLSchemesParams, checkResponse: CoreSiteCheckResponse): Promise<void> {
const ssoNeeded = this.loginHelper.isSSOLoginNeeded(checkResponse.code);
const pageName = 'CoreLoginCredentialsPage';
const pageParams = {
siteUrl: checkResponse.siteUrl,
username: data.username,
urlToOpen: data.redirect,
siteConfig: checkResponse.config
};
let hasSitePluginsLoaded = false;
if (this.sitesProvider.isLoggedIn()) {
// Ask the user before changing site.
await this.domUtils.showConfirm(this.translate.instant('core.contentlinks.confirmurlothersite'));
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);
}
await this.sitesProvider.logout();
}
}
if (ssoNeeded) {
this.loginHelper.confirmAndOpenBrowserForSSOLogin(checkResponse.siteUrl, checkResponse.code, checkResponse.service,
checkResponse.config && checkResponse.config.launchurl);
} else if (!hasSitePluginsLoaded) {
await this.loginHelper.goToNoSitePage(undefined, pageName, pageParams);
}
}
/**
@ -488,6 +493,67 @@ export class CoreCustomURLSchemesProvider {
return url.indexOf(CoreConfigConstants.customurlscheme + '://token=') != -1;
}
/**
* Remove the scheme from a custom URL.
*
* @param url URL to treat.
* @return URL without scheme.
*/
removeCustomURLScheme(url: string): string {
return url.replace(CoreConfigConstants.customurlscheme + '://', '');
}
/**
* Remove the scheme and the "link=" prefix from a link custom URL.
*
* @param url URL to treat.
* @return URL without scheme and prefix.
*/
removeCustomURLLinkScheme(url: string): string {
return url.replace(CoreConfigConstants.customurlscheme + '://link=', '');
}
/**
* Remove the scheme and the "token=" prefix from a token custom URL.
*
* @param url URL to treat.
* @return URL without scheme and prefix.
*/
removeCustomURLTokenScheme(url: string): string {
return url.replace(CoreConfigConstants.customurlscheme + '://token=', '');
}
/**
* Treat error returned by handleCustomURL.
*
* @param error Error data.
*/
treatHandleCustomURLError(error: CoreCustomURLSchemesHandleError): void {
if (error.error == 'Duplicated') {
// Duplicated request
} else if (error.error && error.data && error.data.isSSOToken) {
// An error occurred, display the error and logout the user.
this.loginHelper.treatUserTokenError(error.data.siteUrl, error.error);
this.sitesProvider.logout();
} else {
this.domUtils.showErrorModalDefault(error.error, this.translate.instant('core.login.invalidsite'));
}
}
}
/**
* Error returned by handleCustomURL.
*/
export class CoreCustomURLSchemesHandleError {
/**
* Constructor.
*
* @param error The error message or object.
* @param data Data obtained from the URL (if any).
*/
constructor(public error: any, public data?: CoreCustomURLSchemesParams) { }
}
export class CoreCustomURLSchemes extends makeSingleton(CoreCustomURLSchemesProvider) {}

View File

@ -104,6 +104,33 @@ export class CoreTextUtilsProvider {
return text;
}
/**
* Add some text to an error message.
*
* @param error Error message or object.
* @param text Text to add.
* @return Modified error.
*/
addTextToError(error: string | CoreTextErrorObject, text: string): string | CoreTextErrorObject {
if (typeof error == 'string') {
return error + text;
}
if (error) {
if (typeof error.message == 'string') {
error.message += text;
} else if (typeof error.error == 'string') {
error.error += text;
} else if (typeof error.content == 'string') {
error.content += text;
} else if (typeof error.body == 'string') {
error.body += text;
}
}
return error;
}
/**
* Given an address as a string, return a URL to open the address in maps.
*
@ -414,28 +441,20 @@ export class CoreTextUtilsProvider {
* @param contextLevel The context level.
* @param instanceId The instance ID related to the context.
* @param courseId Course ID the text belongs to. It can be used to improve performance with filters.
* @deprecated since 3.8.3. Please use viewText instead.
*/
expandText(title: string, text: string, component?: string, componentId?: string | number, files?: any[],
filter?: boolean, contextLevel?: string, instanceId?: number, courseId?: number): void {
if (text.length > 0) {
const params: any = {
title: title,
content: text,
component: component,
componentId: componentId,
files: files,
filter: filter,
contextLevel: contextLevel,
instanceId: instanceId,
courseId: courseId
};
// Open a modal with the contents.
params.isModal = true;
const modal = this.modalCtrl.create('CoreViewerTextPage', params);
modal.present();
}
return this.viewText(title, text, {
component,
componentId,
files,
filter,
contextLevel,
instanceId,
courseId,
});
}
/**
@ -1106,6 +1125,50 @@ export class CoreTextUtilsProvider {
return _unserialize((data + ''), 0)[2];
}
/**
* Shows a text on a new page.
*
* @param title Title of the new state.
* @param text Content of the text to be expanded.
* @param component Component to link the embedded files to.
* @param componentId An ID to use in conjunction with the component.
* @param files List of files to display along with the text.
* @param filter Whether the text should be filtered.
* @param contextLevel The context level.
* @param instanceId The instance ID related to the context.
* @param courseId Course ID the text belongs to. It can be used to improve performance with filters.
*/
viewText(title: string, text: string, options?: CoreTextUtilsViewTextOptions): void {
if (text.length > 0) {
options = options || {};
const params: any = {
title: title,
content: text,
isModal: true,
};
Object.assign(params, options);
const modal = this.modalCtrl.create('CoreViewerTextPage', params);
modal.present();
}
}
}
/**
* Options for viewText.
*/
export type CoreTextUtilsViewTextOptions = {
component?: string; // Component to link the embedded files to.
componentId?: string | number; // An ID to use in conjunction with the component.
files?: any[]; // List of files to display along with the text.
filter?: boolean; // Whether the text should be filtered.
contextLevel?: string; // The context level.
instanceId?: number; // The instance ID related to the context.
courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters.
displayCopyButton?: boolean; // Whether to display a button to copy the text.
};
export class CoreTextUtils extends makeSingleton(CoreTextUtilsProvider) {}

View File

@ -13,11 +13,12 @@
// limitations under the License.
import { Injectable, NgZone } from '@angular/core';
import { Platform } from 'ionic-angular';
import { Platform, ModalController } from 'ionic-angular';
import { InAppBrowser, InAppBrowserObject } from '@ionic-native/in-app-browser';
import { Clipboard } from '@ionic-native/clipboard';
import { FileOpener } from '@ionic-native/file-opener';
import { WebIntent } from '@ionic-native/web-intent';
import { QRScanner } from '@ionic-native/qr-scanner';
import { CoreAppProvider } from '../app';
import { CoreDomUtilsProvider } from './dom';
import { CoreMimetypeUtilsProvider } from './mimetype';
@ -28,6 +29,7 @@ import { TranslateService } from '@ngx-translate/core';
import { CoreLangProvider } from '../lang';
import { CoreWSProvider, CoreWSError } from '../ws';
import { CoreFile } from '../file';
import { Subscription } from 'rxjs';
import { makeSingleton } from '@singletons/core.singletons';
/**
@ -63,12 +65,25 @@ export class CoreUtilsProvider {
protected logger;
protected iabInstance: InAppBrowserObject;
protected uniqueIds: {[name: string]: number} = {};
protected qrScanData: {deferred: PromiseDefer, observable: Subscription};
constructor(private iab: InAppBrowser, private appProvider: CoreAppProvider, private clipboard: Clipboard,
private domUtils: CoreDomUtilsProvider, logger: CoreLoggerProvider, private translate: TranslateService,
private platform: Platform, private langProvider: CoreLangProvider, private eventsProvider: CoreEventsProvider,
private fileOpener: FileOpener, private mimetypeUtils: CoreMimetypeUtilsProvider, private webIntent: WebIntent,
private wsProvider: CoreWSProvider, private zone: NgZone, private textUtils: CoreTextUtilsProvider) {
constructor(protected iab: InAppBrowser,
protected appProvider: CoreAppProvider,
protected clipboard: Clipboard,
protected domUtils: CoreDomUtilsProvider,
logger: CoreLoggerProvider,
protected translate: TranslateService,
protected platform: Platform,
protected langProvider: CoreLangProvider,
protected eventsProvider: CoreEventsProvider,
protected fileOpener: FileOpener,
protected mimetypeUtils: CoreMimetypeUtilsProvider,
protected webIntent: WebIntent,
protected wsProvider: CoreWSProvider,
protected zone: NgZone,
protected textUtils: CoreTextUtilsProvider,
protected modalCtrl: ModalController,
protected qrScanner: QRScanner) {
this.logger = logger.getInstance('CoreUtilsProvider');
}
@ -1420,6 +1435,117 @@ export class CoreUtilsProvider {
return debounced;
}
/**
* Check whether the app can scan QR codes.
*
* @return Whether the app can scan QR codes.
*/
canScanQR(): boolean {
return this.appProvider.isMobile();
}
/**
* Open a modal to scan a QR code.
*
* @param title Title of the modal. Defaults to "QR reader".
* @return Promise resolved with the captured text or undefined if cancelled or error.
*/
scanQR(title?: string): Promise<string> {
return new Promise((resolve, reject): void => {
const modal = this.modalCtrl.create('CoreViewerQRScannerPage', {
title: title
}, { cssClass: 'core-modal-fullscreen'});
modal.present();
modal.onDidDismiss((data) => {
resolve(data);
});
});
}
/**
* Start scanning for a QR code.
*
* @return Promise resolved with the QR string, rejected if error or cancelled.
*/
startScanQR(): Promise<string> {
if (!this.appProvider.isMobile()) {
return Promise.reject('QRScanner isn\'t available in desktop apps.');
}
// Ask the user for permission to use the camera.
// The scan method also does this, but since it returns an Observable we wouldn't be able to detect if the user denied.
return this.qrScanner.prepare().then((status) => {
if (!status.authorized) {
// No access to the camera, reject. In android this shouldn't happen, denying access passes through catch.
return Promise.reject('The user denied camera access.');
}
if (this.qrScanData && this.qrScanData.deferred) {
// Already scanning.
return this.qrScanData.deferred.promise;
}
// Start scanning.
this.qrScanData = {
deferred: this.promiseDefer(),
observable: this.qrScanner.scan().subscribe((text) => {
// Text received, stop scanning and return the text.
this.stopScanQR(text, false);
})
};
// Show the camera.
return this.qrScanner.show().then(() => {
document.body.classList.add('core-scanning-qr');
return this.qrScanData.deferred.promise;
}, (err) => {
this.stopScanQR(err, true);
return Promise.reject(err);
});
}).catch((err) => {
err.message = err.message || err._message;
return Promise.reject(err);
});
}
/**
* Stop scanning for QR code. If no param is provided, the app will consider the user cancelled.
*
* @param data If success, the text of the QR code. If error, the error object or message. Undefined for cancelled.
* @param error True if the data belongs to an error, false otherwise.
*/
stopScanQR(data?: any, error?: boolean): void {
if (!this.qrScanData) {
// Not scanning.
return;
}
// Hide camera preview.
document.body.classList.remove('core-scanning-qr');
this.qrScanner.hide();
this.qrScanData.observable.unsubscribe(); // Stop scanning.
if (error) {
this.qrScanData.deferred.reject(data);
} else if (typeof data != 'undefined') {
this.qrScanData.deferred.resolve(data);
} else {
this.qrScanData.deferred.reject({coreCanceled: true});
}
delete this.qrScanData;
}
}
export class CoreUtils extends makeSingleton(CoreUtilsProvider) {}