diff --git a/config.xml b/config.xml index 1282a7a65..cd9f0ab61 100644 --- a/config.xml +++ b/config.xml @@ -170,6 +170,7 @@ + diff --git a/package-lock.json b/package-lock.json index 956c7923b..074dd3600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 941127fae..d1b669d91 100644 --- a/package.json +++ b/package.json @@ -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 } } -} +} \ No newline at end of file diff --git a/scripts/langindex.json b/scripts/langindex.json index c275a91ed..bb5317983 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -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", diff --git a/src/addon/mod/assign/components/index/index.ts b/src/addon/mod/assign/components/index/index.ts index b392abef3..8d8fb9985 100644 --- a/src/addon/mod/assign/components/index/index.ts +++ b/src/addon/mod/assign/components/index/index.ts @@ -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, + }); } } diff --git a/src/addon/mod/assign/components/submission/submission.ts b/src/addon/mod/assign/components/submission/submission.ts index 9c231cabf..6bc9c7282 100644 --- a/src/addon/mod/assign/components/submission/submission.ts +++ b/src/addon/mod/assign/components/submission/submission.ts @@ -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, + }); } } diff --git a/src/addon/mod/assign/feedback/comments/component/comments.ts b/src/addon/mod/assign/feedback/comments/component/comments.ts index 27eb0baa1..1207e8f33 100644 --- a/src/addon/mod/assign/feedback/comments/component/comments.ts +++ b/src/addon/mod/assign/feedback/comments/component/comments.ts @@ -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) { diff --git a/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts index a788de667..ccfb0d3cd 100644 --- a/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts +++ b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts @@ -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 { diff --git a/src/addon/mod/book/components/index/index.ts b/src/addon/mod/book/components/index/index.ts index 9b3fa4574..b52ed71cb 100644 --- a/src/addon/mod/book/components/index/index.ts +++ b/src/addon/mod/book/components/index/index.ts @@ -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'; /** diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 8316ea5fb..54d49f357 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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); + }); }); }; diff --git a/src/app/app.scss b/src/app/app.scss index b384030c3..064b7bfdb 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -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; + } + } +} diff --git a/src/assets/img/login/faq_qrcode.png b/src/assets/img/login/faq_qrcode.png new file mode 100644 index 000000000..cc936b168 Binary files /dev/null and b/src/assets/img/login/faq_qrcode.png differ diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 7f19738e8..7fd738ba5 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -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": "

Every organisation or school has their own custom address for their Moodle site.

To find the address of the Moodle site you want to connect to, do the following:

  1. Open a web browser and go to your school's or organisation's Moodle site login page
  2. 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}}
  3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle App and click \"Connect!\"
  4. Now you can log into your site, using your username and password
  5. ", + "core.login.faqwhatisurlanswer": "

    Every organisation or school has their own custom address for their Moodle site.

    To find the address of the Moodle site you want to connect to, do the following:

    1. Open a web browser and go to your school's or organisation's Moodle site login page
    2. 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}}
    3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle App and click \"Connect!\"
    4. Now you can log into your site, using your username and password
    5. ", "core.login.faqwhatisurlquestion": "What is the URL of my Moodle site? How can I find my school’s site?", + "core.login.faqwhereisqrcode": "Where can I find the QR code?", + "core.login.faqwhereisqrcodeanswer": "

      If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.

      {{$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", diff --git a/src/components/navigation-bar/navigation-bar.ts b/src/components/navigation-bar/navigation-bar.ts index c19aad5ac..5eb878cb2 100644 --- a/src/components/navigation-bar/navigation-bar.ts +++ b/src/components/navigation-bar/navigation-bar.ts @@ -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, + }); } } diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts index b84b2964e..0a333639b 100644 --- a/src/core/course/classes/main-resource-component.ts +++ b/src/core/course/classes/main-resource-component.ts @@ -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, + }); } /** diff --git a/src/core/course/pages/unsupported-module/unsupported-module.ts b/src/core/course/pages/unsupported-module/unsupported-module.ts index a28a2dde6..e63edc461 100644 --- a/src/core/course/pages/unsupported-module/unsupported-module.ts +++ b/src/core/course/pages/unsupported-module/unsupported-module.ts @@ -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, + }); } } diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index b93d3191b..c94e9752e 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -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); diff --git a/src/core/editor/components/rich-text-editor/core-editor-rich-text-editor.html b/src/core/editor/components/rich-text-editor/core-editor-rich-text-editor.html index 20faba16e..4423c4b7a 100644 --- a/src/core/editor/components/rich-text-editor/core-editor-rich-text-editor.html +++ b/src/core/editor/components/rich-text-editor/core-editor-rich-text-editor.html @@ -71,6 +71,11 @@ + + + + + + + + diff --git a/src/core/viewer/pages/qr-scanner/qr-scanner.module.ts b/src/core/viewer/pages/qr-scanner/qr-scanner.module.ts new file mode 100644 index 000000000..db7f83fa7 --- /dev/null +++ b/src/core/viewer/pages/qr-scanner/qr-scanner.module.ts @@ -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 {} diff --git a/src/core/viewer/pages/qr-scanner/qr-scanner.ts b/src/core/viewer/pages/qr-scanner/qr-scanner.ts new file mode 100644 index 000000000..49a2060e7 --- /dev/null +++ b/src/core/viewer/pages/qr-scanner/qr-scanner.ts @@ -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(); + } +} diff --git a/src/core/viewer/pages/text/text.html b/src/core/viewer/pages/text/text.html index 410063705..108d28929 100644 --- a/src/core/viewer/pages/text/text.html +++ b/src/core/viewer/pages/text/text.html @@ -16,3 +16,9 @@ + + + diff --git a/src/core/viewer/pages/text/text.scss b/src/core/viewer/pages/text/text.scss new file mode 100644 index 000000000..4377e35e3 --- /dev/null +++ b/src/core/viewer/pages/text/text.scss @@ -0,0 +1,5 @@ +ion-app.app-root page-core-viewer-text { + ion-footer { + padding: 6px; + } +} diff --git a/src/core/viewer/pages/text/text.ts b/src/core/viewer/pages/text/text.ts index 6c8c1b6f8..935978391 100644 --- a/src/core/viewer/pages/text/text.ts +++ b/src/core/viewer/pages/text/text.ts @@ -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); + } } diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index 9dfe8a746..f5c9f8f4b 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -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(); diff --git a/src/directives/link.ts b/src/directives/link.ts index 5d85e2199..2a18a11e5 100644 --- a/src/directives/link.ts +++ b/src/directives/link.ts @@ -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. diff --git a/src/lang/en.json b/src/lang/en.json index 7764553bb..8d279f23c 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -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", diff --git a/src/providers/urlschemes.ts b/src/providers/urlschemes.ts index 5c22508d2..1ce94172c 100644 --- a/src/providers/urlschemes.ts +++ b/src/providers/urlschemes.ts @@ -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 { + 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 { + async handleCustomURL(url: string): Promise { 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 { - const urlScheme = CoreConfigConstants.customurlscheme + '://'; - if (url.indexOf(urlScheme) == -1) { - return Promise.reject(null); + protected async getCustomURLData(url: string): Promise { + 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 { - const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link='; - if (url.indexOf(contentLinksScheme) == -1) { - return Promise.reject(null); + protected async getCustomURLLinkData(url: string): Promise { + 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 { - const ssoScheme = CoreConfigConstants.customurlscheme + '://token='; - if (url.indexOf(ssoScheme) == -1) { - return Promise.reject(null); + protected async getCustomURLTokenData(url: string): Promise { + 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 { + 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) {} diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index 66e617091..5ba186d9b 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -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) {} diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index b3781a706..49d562d18 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -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 { + 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 { + 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) {}