diff --git a/src/core/constants.ts b/src/core/constants.ts index 2fc8bfc79..ea038eece 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -23,6 +23,8 @@ export class CoreConstants { static SECONDS_MINUTE = 60; static WIFI_DOWNLOAD_THRESHOLD = 104857600; // 100MB. static DOWNLOAD_THRESHOLD = 10485760; // 10MB. + static MINIMUM_FREE_SPACE = 10485760; // 10MB. + static IOS_FREE_SPACE_THRESHOLD = 524288000; // 500MB. static DONT_SHOW_ERROR = 'CoreDontShowError'; static NO_SITE_ID = 'NoSite'; diff --git a/src/core/course/lang/en.json b/src/core/course/lang/en.json index 0a2229b22..314f3a81c 100644 --- a/src/core/course/lang/en.json +++ b/src/core/course/lang/en.json @@ -4,10 +4,12 @@ "activitynotyetviewablesiteupgradeneeded": "Your organisation's Moodle installation needs to be updated.", "allsections": "All sections", "askadmintosupport": "Contact the site administrator and tell them you want to use this activity with the Moodle Mobile app.", + "availablespace": " You currently have about {{available}} free space.", "confirmdeletemodulefiles": "Are you sure you want to delete these files?", - "confirmdownload": "You are about to download {{size}}. Are you sure you want to continue?", - "confirmdownloadunknownsize": "It was not possible to calculate the size of the download. Are you sure you want to continue?", - "confirmpartialdownloadsize": "You are about to download at least {{size}}. Are you sure you want to continue?", + "confirmdownload": "You are about to download {{size}}.{{availableSpace}} Are you sure you want to continue?", + "confirmdownloadunknownsize": "It was not possible to calculate the size of the download.{{availableSpace}} Are you sure you want to continue?", + "confirmpartialdownloadsize": "You are about to download at least {{size}}.{{availableSpace}} Are you sure you want to continue?", + "confirmlimiteddownload": "You are not currently connected to WiFi. ", "contents": "Contents", "couldnotloadsectioncontent": "Could not load the section content. Please try again later.", "couldnotloadsections": "Could not load the sections. Please try again later.", @@ -18,6 +20,8 @@ "errorgetmodule": "Error getting activity data.", "hiddenfromstudents": "Hidden from students", "hiddenoncoursepage": "Available but not shown on course page", + "insufficientavailablespace": "You are trying to download {{size}}. This will leave your device with insufficient space to operate normally. Please clear some storage space first.", + "insufficientavailablequota": "Your device could not allocate space to save this download. It may be reserving space for app and system updates. Please clear some storage space first.", "manualcompletionnotsynced": "Manual completion not synchronised.", "nocontentavailable": "No content available at the moment.", "overriddennotice": "Your final grade from this activity was manually adjusted.", diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index d148c7f72..7b3725174 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -1122,10 +1122,10 @@ export class CoreFilepoolProvider { return Promise.all(promises).then(() => { // Success prefetching, store package as downloaded. return this.storePackageStatus(siteId, CoreConstants.DOWNLOADED, component, componentId, extra); - }).catch(() => { + }).catch((error) => { // Error downloading, go back to previous status and reject the promise. return this.setPackagePreviousStatus(siteId, component, componentId).then(() => { - return Promise.reject(null); + return Promise.reject(error); }); }); @@ -2566,18 +2566,26 @@ export class CoreFilepoolProvider { dropFromQueue = true; } + let errorMessage = null; + // Some Android devices restrict the amount of usable storage using quotas. + // If this quota would be exceeded by the download, it throws an exception. + // We catch this exception here, and report a meaningful error message to the user. + if (errorObject instanceof FileTransferError && errorObject.exception.includes('EDQUOT')) { + errorMessage = 'core.course.insufficientavailablequota'; + } + if (dropFromQueue) { this.logger.debug('Item dropped from queue due to error: ' + fileUrl, errorObject); return this.removeFromQueue(siteId, fileId).catch(() => { // Consider this as a silent error, never reject the promise here. }).then(() => { - this.treatQueueDeferred(siteId, fileId, false); + this.treatQueueDeferred(siteId, fileId, false, errorMessage); this.notifyFileDownloadError(siteId, fileId); }); } else { // We considered the file as legit but did not get it, failure. - this.treatQueueDeferred(siteId, fileId, false); + this.treatQueueDeferred(siteId, fileId, false, errorMessage); this.notifyFileDownloadError(siteId, fileId); return Promise.reject(errorObject); @@ -2912,13 +2920,14 @@ export class CoreFilepoolProvider { * @param {string} siteId The site ID. * @param {string} fileId The file ID. * @param {boolean} resolve True if promise should be resolved, false if it should be rejected. + * @param {string} error String identifier for error message, if rejected. */ - protected treatQueueDeferred(siteId: string, fileId: string, resolve: boolean): void { + protected treatQueueDeferred(siteId: string, fileId: string, resolve: boolean, error?: string): void { if (this.queueDeferreds[siteId] && this.queueDeferreds[siteId][fileId]) { if (resolve) { this.queueDeferreds[siteId][fileId].resolve(); } else { - this.queueDeferreds[siteId][fileId].reject(); + this.queueDeferreds[siteId][fileId].reject(error); } delete this.queueDeferreds[siteId][fileId]; } diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 912b0dc49..1af9102c2 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -23,6 +23,7 @@ import { CoreTextUtilsProvider } from './text'; import { CoreAppProvider } from '../app'; import { CoreConfigProvider } from '../config'; import { CoreUrlUtilsProvider } from './url'; +import { CoreFileProvider } from '@providers/file'; import { CoreConstants } from '@core/constants'; import { CoreBSTooltipComponent } from '@components/bs-tooltip/bs-tooltip'; import { Md5 } from 'ts-md5/dist/md5'; @@ -66,7 +67,8 @@ export class CoreDomUtilsProvider { constructor(private translate: TranslateService, private loadingCtrl: LoadingController, private toastCtrl: ToastController, private alertCtrl: AlertController, private textUtils: CoreTextUtilsProvider, private appProvider: CoreAppProvider, private platform: Platform, private configProvider: CoreConfigProvider, private urlUtils: CoreUrlUtilsProvider, - private modalCtrl: ModalController, private sanitizer: DomSanitizer, private popoverCtrl: PopoverController) { + private modalCtrl: ModalController, private sanitizer: DomSanitizer, private popoverCtrl: PopoverController, + private fileProvider: CoreFileProvider) { // Check if debug messages should be displayed. configProvider.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { @@ -128,28 +130,73 @@ export class CoreDomUtilsProvider { */ confirmDownloadSize(size: any, message?: string, unknownMessage?: string, wifiThreshold?: number, limitedThreshold?: number, alwaysConfirm?: boolean): Promise { - wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; - limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; + const readableSize = this.textUtils.bytesToSize(size.size, 2); - if (size.size < 0 || (size.size == 0 && !size.total)) { - // Seems size was unable to be calculated. Show a warning. - unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; + const getAvailableBytes = new Promise((resolve): void => { + if (this.appProvider.isDesktop()) { + // Free space calculation is not supported on desktop. + resolve(null); + } else { + this.fileProvider.calculateFreeSpace().then((availableBytes) => { + if (this.platform.is('android')) { + return availableBytes; + } else { + // Space calculation is not accurate on iOS, but it gets more accurate when space is lower. + // We'll only use it when space is <500MB, or we're downloading more than twice the reported space. + if (availableBytes < CoreConstants.IOS_FREE_SPACE_THRESHOLD || size.size > availableBytes / 2) { + return availableBytes; + } else { + return null; + } + } + }).then((availableBytes) => { + resolve(availableBytes); + }); + } + }); - return this.showConfirm(this.translate.instant(unknownMessage)); - } else if (!size.total) { - // Filesize is only partial. - const readableSize = this.textUtils.bytesToSize(size.size, 2); + const getAvailableSpace = getAvailableBytes.then((availableBytes: number) => { + if (availableBytes === null) { + return ''; + } else { + const availableSize = this.textUtils.bytesToSize(availableBytes, 2); + if (this.platform.is('android') && size.size > availableBytes - CoreConstants.MINIMUM_FREE_SPACE) { + return Promise.reject(this.translate.instant('core.course.insufficientavailablespace', { size: readableSize })); + } - return this.showConfirm(this.translate.instant('core.course.confirmpartialdownloadsize', { size: readableSize })); - } else if (alwaysConfirm || size.size >= wifiThreshold || + return this.translate.instant('core.course.availablespace', {available: availableSize}); + } + }); + + return getAvailableSpace.then((availableSpace) => { + wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; + limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; + + let wifiPrefix = ''; + if (this.appProvider.isNetworkAccessLimited()) { + wifiPrefix = this.translate.instant('core.course.confirmlimiteddownload'); + } + + if (size.size < 0 || (size.size == 0 && !size.total)) { + // Seems size was unable to be calculated. Show a warning. + unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; + + return this.showConfirm(wifiPrefix + this.translate.instant(unknownMessage, {availableSpace: availableSpace})); + } else if (!size.total) { + // Filesize is only partial. + + return this.showConfirm(wifiPrefix + this.translate.instant('core.course.confirmpartialdownloadsize', + { size: readableSize, availableSpace: availableSpace })); + } else if (alwaysConfirm || size.size >= wifiThreshold || (this.appProvider.isNetworkAccessLimited() && size.size >= limitedThreshold)) { - message = message || 'core.course.confirmdownload'; - const readableSize = this.textUtils.bytesToSize(size.size, 2); + message = message || 'core.course.confirmdownload'; - return this.showConfirm(this.translate.instant(message, { size: readableSize })); - } + return this.showConfirm(wifiPrefix + this.translate.instant(message, + { size: readableSize, availableSpace: availableSpace })); + } - return Promise.resolve(); + return Promise.resolve(); + }); } /** diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index 5bdb70ca4..7c30d9ec9 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -113,7 +113,7 @@ export class CoreTextUtilsProvider { */ bytesToSize(bytes: number, precision: number = 2): string { - if (typeof bytes == 'undefined' || bytes < 0) { + if (typeof bytes == 'undefined' || bytes === null || bytes < 0) { return this.translate.instant('core.notapplicable'); }