diff --git a/scripts/langindex.json b/scripts/langindex.json index df9739345..db78fc2ed 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1373,6 +1373,8 @@ "core.cannotconnecttrouble": "local_moodlemobileapp", "core.cannotconnectverify": "local_moodlemobileapp", "core.cannotdownloadfiles": "local_moodlemobileapp", + "core.cannotopeninapp": "local_moodlemobileapp", + "core.cannotopeninappdownload": "local_moodlemobileapp", "core.captureaudio": "local_moodlemobileapp", "core.capturedimage": "local_moodlemobileapp", "core.captureimage": "local_moodlemobileapp", @@ -1525,6 +1527,7 @@ "core.done": "survey", "core.download": "moodle", "core.downloaded": "local_moodlemobileapp", + "core.downloadfile": "moodle", "core.downloading": "local_moodlemobileapp", "core.edit": "moodle", "core.editor.autosavesucceeded": "editor_atto", @@ -1916,6 +1919,7 @@ "core.offline": "message", "core.ok": "moodle", "core.online": "message", + "core.openfile": "local_moodlemobileapp", "core.openfullimage": "local_moodlemobileapp", "core.openinbrowser": "local_moodlemobileapp", "core.openmodinbrowser": "local_moodlemobileapp", diff --git a/src/app/app.scss b/src/app/app.scss index 05557bc56..186461c52 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -873,6 +873,10 @@ ion-app.app-root { height: 100% !important; } + .core-modal-force-on-top { + z-index: 100000 !important; + } + @media only screen and (min-height: 400px) and (min-width: 300px) { .core-modal-lateral { @include core-split-area-end(); diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index fafd7b6f2..5be7a28c6 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1374,6 +1374,8 @@ "core.cannotconnecttrouble": "We're having trouble connecting to your site.", "core.cannotconnectverify": "Please check the address is correct.", "core.cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", + "core.cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?", + "core.cannotopeninappdownload": "This file may not work as expected on this device. Would you like to download it anyway?", "core.captureaudio": "Record audio", "core.capturedimage": "Taken picture.", "core.captureimage": "Take picture", @@ -1526,6 +1528,7 @@ "core.done": "Done", "core.download": "Download", "core.downloaded": "Downloaded", + "core.downloadfile": "Download file", "core.downloading": "Downloading", "core.edit": "Edit", "core.editor.autosavesucceeded": "Draft saved.", @@ -1917,6 +1920,7 @@ "core.offline": "Offline", "core.ok": "OK", "core.online": "Online", + "core.openfile": "Open file", "core.openfullimage": "Click here to display the full size image", "core.openinbrowser": "Open in browser", "core.openmodinbrowser": "Open {{$a}} in browser", diff --git a/src/components/file/file.ts b/src/components/file/file.ts index 5df003e25..34c89be6a 100644 --- a/src/components/file/file.ts +++ b/src/components/file/file.ts @@ -148,7 +148,7 @@ export class CoreFileComponent implements OnInit, OnDestroy { * @param e Click event. * @param openAfterDownload Whether the file should be opened after download. */ - download(e?: Event, openAfterDownload: boolean = false): void { + async download(e?: Event, openAfterDownload: boolean = false): Promise { e && e.preventDefault(); e && e.stopPropagation(); @@ -181,32 +181,45 @@ export class CoreFileComponent implements OnInit, OnDestroy { if (openAfterDownload) { // File needs to be opened now. - this.openFile().catch((error) => { + try { + await this.openFile(); + } catch (error) { this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); - }); + } } else { - // File doesn't need to be opened (it's a prefetch). Show confirm modal if file size is defined and it's big. - this.pluginFileDelegate.getFileSize({fileurl: this.fileUrl, filesize: this.fileSize}, this.siteId).then((size) => { + // File doesn't need to be opened (it's a prefetch). + if (!this.fileHelper.isOpenableInApp(this.file)) { + try { + await this.fileHelper.showConfirmOpenUnsupportedFile(true); + } catch (error) { + return; // Cancelled, stop. + } + } - const promise = size ? this.domUtils.confirmDownloadSize({ size: size, total: true }) : Promise.resolve(); + try { + // Show confirm modal if file size is defined and it's big. + const size = await this.pluginFileDelegate.getFileSize({fileurl: this.fileUrl, filesize: this.fileSize}, + this.siteId); - return promise.then(() => { - // User confirmed, add the file to queue. - return this.filepoolProvider.invalidateFileByUrl(this.siteId, this.fileUrl).finally(() => { - this.isDownloading = true; + if (size) { + await this.domUtils.confirmDownloadSize({ size: size, total: true }); + } - this.filepoolProvider.addToQueueByUrl(this.siteId, this.fileUrl, this.component, - this.componentId, this.timemodified, undefined, undefined, 0, this.file).catch((error) => { - this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); - this.calculateState(); - }); - }); - }).catch(() => { - // User cancelled. - }); - }).catch((error) => { + // User confirmed, add the file to queue. + await this.utils.ignoreErrors(this.filepoolProvider.invalidateFileByUrl(this.siteId, this.fileUrl)); + + this.isDownloading = true; + + try { + await this.filepoolProvider.addToQueueByUrl(this.siteId, this.fileUrl, this.component, + this.componentId, this.timemodified, undefined, undefined, 0, this.file); + } catch (error) { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + this.calculateState(); + } + } catch (error) { this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); - }); + } } } diff --git a/src/components/local-file/local-file.ts b/src/components/local-file/local-file.ts index cd0c573a3..eba54e903 100644 --- a/src/components/local-file/local-file.ts +++ b/src/components/local-file/local-file.ts @@ -15,6 +15,7 @@ import { Component, Input, Output, OnInit, EventEmitter, ViewChild, ElementRef } from '@angular/core'; import { CoreEventsProvider } from '@providers/events'; import { CoreFileProvider } from '@providers/file'; +import { CoreFileHelper } from '@providers/file-helper'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; @@ -103,7 +104,7 @@ export class CoreLocalFileComponent implements OnInit { * * @param e Click event. */ - fileClicked(e: Event): void { + async fileClicked(e: Event): Promise { if (this.editMode) { return; } @@ -114,6 +115,14 @@ export class CoreLocalFileComponent implements OnInit { if (this.utils.isTrueOrOne(this.overrideClick) && this.onClick.observers.length) { this.onClick.emit(); } else { + if (!CoreFileHelper.instance.isOpenableInApp(this.file)) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + this.utils.openFile(this.file.toURL()); } } diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index ec7939c3d..99e181b96 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -585,6 +585,10 @@ export class CoreCourseHelperProvider { return Promise.reject(this.utils.createFakeWSError('core.filenotfound', true)); } + if (!this.fileHelper.isOpenableInApp(module.contents[0])) { + return this.fileHelper.showConfirmOpenUnsupportedFile(); + } + }).then(() => { return this.sitesProvider.getSite(siteId); }).then((site) => { const mainFile = files[0], diff --git a/src/directives/link.ts b/src/directives/link.ts index 2384bbefa..9b8e96fe9 100644 --- a/src/directives/link.ts +++ b/src/directives/link.ts @@ -14,6 +14,7 @@ import { Directive, Input, OnInit, ElementRef, Optional } from '@angular/core'; import { NavController, Content } from 'ionic-angular'; +import { CoreFileHelper } from '@providers/file-helper'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; @@ -21,7 +22,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreTextUtilsProvider } from '@providers/utils/text'; -import { CoreCustomURLSchemesProvider, CoreCustomURLSchemesHandleError } from '@providers/urlschemes'; +import { CoreCustomURLSchemesProvider } from '@providers/urlschemes'; /** * Directive to open a link in external browser. @@ -94,14 +95,27 @@ export class CoreLinkDirective implements OnInit { * Convenience function to correctly navigate, open file or url in the browser. * * @param href HREF to be opened. + * @return Promise resolved when done. */ - protected navigate(href: string): void { + protected async navigate(href: string): Promise { if (this.urlUtils.isLocalFileUrl(href)) { // We have a local file. - this.utils.openFile(href).catch((error) => { + const filename = href.substr(href.lastIndexOf('/') + 1); + + if (!CoreFileHelper.instance.isOpenableInApp({ filename })) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + + try { + await this.utils.openFile(href); + } catch (error) { this.domUtils.showErrorModal(error); - }); + } } else if (href.charAt(0) == '#') { href = href.substr(1); // In site links @@ -113,9 +127,11 @@ export class CoreLinkDirective implements OnInit { this.domUtils.scrollToElementBySelector(this.content, '#' + href + ', [name=\'' + href + '\']'); } } else if (this.urlSchemesProvider.isCustomURL(href)) { - this.urlSchemesProvider.handleCustomURL(href).catch((error: CoreCustomURLSchemesHandleError) => { + try { + await this.urlSchemesProvider.handleCustomURL(href); + } catch (error) { this.urlSchemesProvider.treatHandleCustomURLError(error); - }); + } } else { // It's an external link, we will open with browser. Check if we need to auto-login. @@ -139,9 +155,9 @@ export class CoreLinkDirective implements OnInit { if (this.autoLogin == 'yes') { if (this.inApp) { - this.sitesProvider.getCurrentSite().openInAppWithAutoLogin(href); + await this.sitesProvider.getCurrentSite().openInAppWithAutoLogin(href); } else { - this.sitesProvider.getCurrentSite().openInBrowserWithAutoLogin(href); + await this.sitesProvider.getCurrentSite().openInBrowserWithAutoLogin(href); } } else if (this.autoLogin == 'no') { if (this.inApp) { @@ -151,9 +167,9 @@ export class CoreLinkDirective implements OnInit { } } else { if (this.inApp) { - this.sitesProvider.getCurrentSite().openInAppWithAutoLoginIfSameSite(href); + await this.sitesProvider.getCurrentSite().openInAppWithAutoLoginIfSameSite(href); } else { - this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(href); + await this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(href); } } } diff --git a/src/lang/en.json b/src/lang/en.json index 40da0174b..f00f084d3 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -16,6 +16,8 @@ "cannotconnecttrouble": "We're having trouble connecting to your site.", "cannotconnectverify": "Please check the address is correct.", "cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", + "cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?", + "cannotopeninappdownload": "This file may not work as expected on this device. Would you like to download it anyway?", "captureaudio": "Record audio", "capturedimage": "Taken picture.", "captureimage": "Take picture", @@ -82,6 +84,7 @@ "done": "Done", "download": "Download", "downloaded": "Downloaded", + "downloadfile": "Download file", "downloading": "Downloading", "edit": "Edit", "emptysplit": "This page will appear blank if the left panel is empty or is loading.", @@ -200,6 +203,7 @@ "offline": "Offline", "ok": "OK", "online": "Online", + "openfile": "Open file", "openfullimage": "Click here to display the full size image", "openinbrowser": "Open in browser", "openmodinbrowser": "Open {{$a}} in browser", diff --git a/src/providers/file-helper.ts b/src/providers/file-helper.ts index 9a9ab8eaf..9ec8d77a9 100644 --- a/src/providers/file-helper.ts +++ b/src/providers/file-helper.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from './app'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreFileProvider } from './file'; import { CoreFilepoolProvider } from './filepool'; import { CoreSitesProvider } from './sites'; @@ -30,7 +31,8 @@ import { makeSingleton } from '@singletons/core.singletons'; @Injectable() export class CoreFileHelperProvider { - constructor(protected fileProvider: CoreFileProvider, + constructor(protected domUtils: CoreDomUtilsProvider, + protected fileProvider: CoreFileProvider, protected filepoolProvider: CoreFilepoolProvider, protected sitesProvider: CoreSitesProvider, protected appProvider: CoreAppProvider, @@ -49,63 +51,58 @@ export class CoreFileHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Resolved on success. */ - downloadAndOpenFile(file: any, component: string, componentId: string | number, state?: string, - onProgress?: (event: any) => any, siteId?: string): Promise { + async downloadAndOpenFile(file: any, component: string, componentId: string | number, state?: string, + onProgress?: (event: any) => any, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); - const fileUrl = this.getFileUrl(file), - timemodified = this.getFileTimemodified(file); + const fileUrl = this.getFileUrl(file); + const timemodified = this.getFileTimemodified(file); + + if (!this.isOpenableInApp(file)) { + await this.showConfirmOpenUnsupportedFile(); + } + + let url = await this.downloadFileIfNeeded(file, fileUrl, component, componentId, timemodified, state, onProgress, siteId); + + if (!url) { + return; + } + + if (!CoreUrlUtils.instance.isLocalFileUrl(url)) { + /* In iOS, if we use the same URL in embedded browser and background download then the download only + downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */ + url = url + '#moodlemobile-embedded'; + + try { + await this.utils.openOnlineFile(url); - return this.downloadFileIfNeeded(file, fileUrl, component, componentId, timemodified, state, onProgress, siteId) - .then((url) => { - if (!url) { return; + } catch (error) { + // Error opening the file, some apps don't allow opening online files. + if (!this.fileProvider.isAvailable()) { + throw error; + } + + // Get the state. + if (!state) { + state = await this.filepoolProvider.getFileStateByUrl(siteId, fileUrl, timemodified); + } + + if (state == CoreConstants.DOWNLOADING) { + throw new Error(this.translate.instant('core.erroropenfiledownloading')); + } + + if (state === CoreConstants.NOT_DOWNLOADED) { + // File is not downloaded, download and then return the local URL. + url = await this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); + } else { + // File is outdated and can't be opened in online, return the local URL. + url = await this.filepoolProvider.getInternalUrlByUrl(siteId, fileUrl); + } } + } - if (!CoreUrlUtils.instance.isLocalFileUrl(url)) { - /* In iOS, if we use the same URL in embedded browser and background download then the download only - downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */ - url = url + '#moodlemobile-embedded'; - - return this.utils.openOnlineFile(url).catch((error) => { - // Error opening the file, some apps don't allow opening online files. - if (!this.fileProvider.isAvailable()) { - return Promise.reject(error); - } - - let promise; - - // Get the state. - if (state) { - promise = Promise.resolve(state); - } else { - promise = this.filepoolProvider.getFileStateByUrl(siteId, fileUrl, timemodified); - } - - return promise.then((state) => { - if (state == CoreConstants.DOWNLOADING) { - return Promise.reject(this.translate.instant('core.erroropenfiledownloading')); - } - - let promise; - - if (state === CoreConstants.NOT_DOWNLOADED) { - // File is not downloaded, download and then return the local URL. - promise = this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); - } else { - // File is outdated and can't be opened in online, return the local URL. - promise = this.filepoolProvider.getInternalUrlByUrl(siteId, fileUrl); - } - - return promise.then((url) => { - return this.utils.openFile(url); - }); - }); - }); - } else { - return this.utils.openFile(url); - } - }); + return this.utils.openFile(url); } /** @@ -339,6 +336,52 @@ export class CoreFileHelperProvider { throw new Error('Couldn\'t determine file size: ' + file.fileurl); } + + /** + * Is the file openable in app. + * + * @param file The file to check. + * @return bool. + */ + isOpenableInApp(file: {filename?: string, name?: string}): boolean { + const re = /(?:\.([^.]+))?$/; + + const ext = re.exec(file.filename || file.name)[1]; + + return !this.isFileTypeExcludedInApp(ext); + } + + /** + * Show a confirm asking the user if we wants to open the file. + * + * @param onlyDownload Whether the user is only downloading the file, not opening it. + * @return Promise resolved if confirmed, rejected otherwise. + */ + showConfirmOpenUnsupportedFile(onlyDownload?: boolean): Promise { + const message = this.translate.instant('core.cannotopeninapp' + (onlyDownload ? 'download' : '')); + const okButton = this.translate.instant(onlyDownload ? 'core.downloadfile' : 'core.openfile'); + + return this.domUtils.showConfirm(message, undefined, okButton, undefined, { cssClass: 'core-modal-force-on-top' }); + } + + /** + * Is the file type excluded to open in app. + * + * @param file The file to check. + * @return bool. + */ + isFileTypeExcludedInApp(fileType: string): boolean { + const currentSite = this.sitesProvider.getCurrentSite(); + const fileTypeExcludeList = currentSite && currentSite.getStoredConfig('tool_mobile_filetypeexclusionlist'); + + if (!fileTypeExcludeList) { + return false; + } + + const regEx = new RegExp('(,|^)' + fileType + '(,|$)', 'g'); + + return !!fileTypeExcludeList.match(regEx); + } } export class CoreFileHelper extends makeSingleton(CoreFileHelperProvider) {} diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 9297320de..0a0d7a016 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -1287,7 +1287,7 @@ export class CoreDomUtilsProvider { ]; if (!title) { - options.cssClass = 'core-nohead'; + options.cssClass = (options.cssClass || '') + ' core-nohead'; } this.showAlertWithOptions(options, 0); diff --git a/src/providers/utils/iframe.ts b/src/providers/utils/iframe.ts index fc9d3afb9..0fdc5af74 100644 --- a/src/providers/utils/iframe.ts +++ b/src/providers/utils/iframe.ts @@ -18,6 +18,7 @@ import { TranslateService } from '@ngx-translate/core'; import { Network } from '@ionic-native/network'; import { CoreApp, CoreAppProvider } from '../app'; import { CoreFileProvider } from '../file'; +import { CoreFileHelper } from '../file-helper'; import { CoreLoggerProvider } from '../logger'; import { CoreSitesProvider } from '../sites'; import { CoreDomUtilsProvider } from './dom'; @@ -390,6 +391,16 @@ export class CoreIframeUtilsProvider { } } else if (this.urlUtils.isLocalFileUrl(url)) { // It's a local file. + const filename = url.substr(url.lastIndexOf('/') + 1); + + if (!CoreFileHelper.instance.isOpenableInApp({ filename })) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + try { await this.utils.openFile(url); } catch (error) { @@ -409,9 +420,10 @@ export class CoreIframeUtilsProvider { * @param link Data of the link clicked. * @param element Frame element. * @param event Click event. + * @return Promise resolved when done. */ - protected linkClicked(link: {href: string, target?: string}, element?: HTMLFrameElement | HTMLObjectElement, event?: Event) - : void { + protected async linkClicked(link: {href: string, target?: string}, element?: HTMLFrameElement | HTMLObjectElement, + event?: Event): Promise { if (event && event.defaultPrevented) { // Event already prevented by some other code. return; @@ -445,14 +457,27 @@ export class CoreIframeUtilsProvider { if (!this.sitesProvider.isLoggedIn()) { this.utils.openInBrowser(link.href); } else { - this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(link.href); + await this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(link.href); } } else if (link.target == '_parent' || link.target == '_top' || link.target == '_blank') { // Opening links with _parent, _top or _blank can break the app. We'll open it in InAppBrowser. event && event.preventDefault(); - this.utils.openFile(link.href).catch((error) => { + + const filename = link.href.substr(link.href.lastIndexOf('/') + 1); + + if (!CoreFileHelper.instance.isOpenableInApp({ filename })) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + + try { + await this.utils.openFile(link.href); + } catch (error) { this.domUtils.showErrorModal(error); - }); + } } else if (CoreApp.instance.isIOS() && (!link.target || link.target == '_self') && element) { // In cordova ios 4.1.0 links inside iframes stopped working. We'll manually treat them. event && event.preventDefault(); diff --git a/src/singletons/window.ts b/src/singletons/window.ts index e95e6dc6e..c17855ab1 100644 --- a/src/singletons/window.ts +++ b/src/singletons/window.ts @@ -13,6 +13,7 @@ // limitations under the License. import { NavController } from 'ionic-angular'; +import { CoreFileHelper } from '@providers/file-helper'; import { CoreSites } from '@providers/sites'; import { CoreUrlUtils } from '@providers/utils/url'; import { CoreUtils } from '@providers/utils/utils'; @@ -43,6 +44,16 @@ export class CoreWindow { */ static async open(url: string, name?: string, options?: CoreWindowOpenOptions): Promise { if (CoreUrlUtils.instance.isLocalFileUrl(url)) { + const filename = url.substr(url.lastIndexOf('/') + 1); + + if (!CoreFileHelper.instance.isOpenableInApp({ filename })) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + await CoreUtils.instance.openFile(url); } else { let treated: boolean;