MOBILE-3320 resource: Fix 'Open with' with streaming and external repos

main
Dani Palou 2021-06-09 11:38:05 +02:00
parent 9dfe556066
commit edf5763809
9 changed files with 129 additions and 27 deletions

View File

@ -1993,6 +1993,7 @@
"core.percentagenumber": "local_moodlemobileapp", "core.percentagenumber": "local_moodlemobileapp",
"core.phone": "moodle", "core.phone": "moodle",
"core.pictureof": "moodle", "core.pictureof": "moodle",
"core.play": "local_moodlemobileapp",
"core.previous": "moodle", "core.previous": "moodle",
"core.proceed": "moodle", "core.proceed": "moodle",
"core.pulltorefresh": "local_moodlemobileapp", "core.pulltorefresh": "local_moodlemobileapp",

View File

@ -47,11 +47,18 @@
<ng-container *ngIf="mode == 'external'"> <ng-container *ngIf="mode == 'external'">
<ion-button expand="block" class="ion-margin" (click)="open(openFileAction.OPEN)"> <ion-button expand="block" class="ion-margin" (click)="open(openFileAction.OPEN)">
<ion-icon name="far-file" slot="start" aria-hidden="true"></ion-icon> <ng-container *ngIf="isStreamedFile">
{{ 'addon.mod_resource.openthefile' | translate }} <ion-icon name="fas-play" slot="start" aria-hidden="true"></ion-icon>
{{ 'core.play' | translate }}
</ng-container>
<ng-container *ngIf="!isStreamedFile">
<ion-icon name="far-file" slot="start" aria-hidden="true"></ion-icon>
{{ 'addon.mod_resource.openthefile' | translate }}
</ng-container>
</ion-button> </ion-button>
<ion-button *ngIf="isIOS" expand="block" class="ion-margin" (click)="open(openFileAction.OPEN_WITH)"> <ion-button *ngIf="isIOS && (!shouldOpenInBrowser || !isOnline)" expand="block" class="ion-margin"
(click)="open(openFileAction.OPEN_WITH)">
<ion-icon name="far-share-square" slot="start" aria-hidden="true"></ion-icon> <ion-icon name="far-share-square" slot="start" aria-hidden="true"></ion-icon>
{{ 'core.openwith' | translate }} {{ 'core.openwith' | translate }}
</ion-button> </ion-button>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnInit, Optional } from '@angular/core'; import { Component, OnDestroy, OnInit, Optional } from '@angular/core';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { import {
CoreCourseModuleMainResourceComponent, CoreCourseModuleMainResourceComponent,
@ -21,10 +21,13 @@ import { CoreCourseContentsPage } from '@features/course/pages/contents/contents
import { CoreCourse, CoreCourseWSModule } from '@features/course/services/course'; import { CoreCourse, CoreCourseWSModule } from '@features/course/services/course';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreFileHelper } from '@services/file-helper';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils, OpenFileAction } from '@services/utils/utils'; import { CoreUtils, OpenFileAction } from '@services/utils/utils';
import { Translate } from '@singletons'; import { Network, NgZone, Translate } from '@singletons';
import { Subscription } from 'rxjs';
import { import {
AddonModResource, AddonModResource,
AddonModResourceCustomData, AddonModResourceCustomData,
@ -40,7 +43,7 @@ import { AddonModResourceHelper } from '../../services/resource-helper';
selector: 'addon-mod-resource-index', selector: 'addon-mod-resource-index',
templateUrl: 'addon-mod-resource-index.html', templateUrl: 'addon-mod-resource-index.html',
}) })
export class AddonModResourceIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit { export class AddonModResourceIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy {
component = AddonModResourceProvider.COMPONENT; component = AddonModResourceProvider.COMPONENT;
@ -52,19 +55,35 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource
warning = ''; warning = '';
isIOS = false; isIOS = false;
openFileAction = OpenFileAction; openFileAction = OpenFileAction;
isOnline = false;
isStreamedFile = false;
shouldOpenInBrowser = false;
protected onlineObserver?: Subscription;
constructor(@Optional() courseContentsPage?: CoreCourseContentsPage) { constructor(@Optional() courseContentsPage?: CoreCourseContentsPage) {
super('AddonModResourceIndexComponent', courseContentsPage); super('AddonModResourceIndexComponent', courseContentsPage);
} }
/** /**
* Component being initialized. * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
super.ngOnInit(); super.ngOnInit();
this.canGetResource = AddonModResource.isGetResourceWSAvailable(); this.canGetResource = AddonModResource.isGetResourceWSAvailable();
this.isIOS = CoreApp.isIOS(); this.isIOS = CoreApp.isIOS();
this.isOnline = CoreApp.isOnline();
if (this.isIOS) {
// Refresh online status when changes.
this.onlineObserver = Network.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.run(() => {
this.isOnline = CoreApp.isOnline();
});
});
}
await this.loadContent(); await this.loadContent();
try { try {
@ -76,19 +95,14 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource
} }
/** /**
* Perform the invalidate content function. * @inheritdoc
*
* @return Resolved when done.
*/ */
protected async invalidateContent(): Promise<void> { protected async invalidateContent(): Promise<void> {
return AddonModResource.invalidateContent(this.module.id, this.courseId); return AddonModResource.invalidateContent(this.module.id, this.courseId);
} }
/** /**
* Download resource contents. * @inheritdoc
*
* @param refresh Whether we're refreshing data.
* @return Promise resolved when done.
*/ */
protected async fetchContent(refresh?: boolean): Promise<void> { protected async fetchContent(refresh?: boolean): Promise<void> {
// Load module contents if needed. Passing refresh is needed to force reloading contents. // Load module contents if needed. Passing refresh is needed to force reloading contents.
@ -150,6 +164,14 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource
} else { } else {
this.mode = 'external'; this.mode = 'external';
this.warning = ''; this.warning = '';
if (this.isIOS) {
this.shouldOpenInBrowser = CoreFileHelper.shouldOpenInBrowser(this.module.contents[0]);
}
const mimetype = await CoreUtils.getMimeTypeFromUrl(CoreFileHelper.getFileUrl(this.module.contents[0]));
this.isStreamedFile = CoreMimetypeUtils.isStreamedMimetype(mimetype);
} }
} finally { } finally {
this.fillContextMenu(refresh); this.fillContextMenu(refresh);
@ -179,4 +201,12 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource
await CoreSites.getCurrentSite()?.openInBrowserWithAutoLoginIfSameSite(this.module.url!); await CoreSites.getCurrentSite()?.openInBrowserWithAutoLoginIfSameSite(this.module.url!);
} }
/**
* @inheritdoc
*/
ngOnDestroy(): void {
super.ngOnDestroy();
this.onlineObserver?.unsubscribe();
}
} }

View File

@ -691,11 +691,19 @@ export class CoreCourseHelperProvider {
// Check if the file should be opened in browser. // Check if the file should be opened in browser.
if (CoreFileHelper.shouldOpenInBrowser(mainFile)) { if (CoreFileHelper.shouldOpenInBrowser(mainFile)) {
return this.openModuleFileInBrowser(mainFile.fileurl, site, module, courseId, component, componentId, files); return this.openModuleFileInBrowser(mainFile.fileurl, site, module, courseId, component, componentId, files, options);
} }
// File shouldn't be opened in browser. Download the module if it needs to be downloaded. // File shouldn't be opened in browser. Download the module if it needs to be downloaded.
const result = await this.downloadModuleWithMainFileIfNeeded(module, courseId, component || '', componentId, files, siteId); const result = await this.downloadModuleWithMainFileIfNeeded(
module,
courseId,
component || '',
componentId,
files,
siteId,
options,
);
if (CoreUrlUtils.isLocalFileUrl(result.path)) { if (CoreUrlUtils.isLocalFileUrl(result.path)) {
return CoreUtils.openFile(result.path, options); return CoreUtils.openFile(result.path, options);
@ -740,6 +748,7 @@ export class CoreCourseHelperProvider {
* @param component The component to link the files to. * @param component The component to link the files to.
* @param componentId An ID to use in conjunction with the component. * @param componentId An ID to use in conjunction with the component.
* @param files List of files of the module. If not provided, use module.contents. * @param files List of files of the module. If not provided, use module.contents.
* @param options Options to open the file. Only used if not opened in browser.
* @return Resolved on success. * @return Resolved on success.
*/ */
protected async openModuleFileInBrowser( protected async openModuleFileInBrowser(
@ -750,6 +759,7 @@ export class CoreCourseHelperProvider {
component?: string, component?: string,
componentId?: string | number, componentId?: string | number,
files?: CoreCourseModuleContentFile[], files?: CoreCourseModuleContentFile[],
options: CoreUtilsOpenFileOptions = {},
): Promise<void> { ): Promise<void> {
if (!CoreApp.isOnline()) { if (!CoreApp.isOnline()) {
// Not online, get the offline file. It will fail if not found. // Not online, get the offline file. It will fail if not found.
@ -760,7 +770,7 @@ export class CoreCourseHelperProvider {
throw new CoreNetworkError(); throw new CoreNetworkError();
} }
return CoreUtils.openFile(path); return CoreUtils.openFile(path, options);
} }
// Open in browser. // Open in browser.
@ -791,6 +801,7 @@ export class CoreCourseHelperProvider {
* @param componentId An ID to use in conjunction with the component. * @param componentId An ID to use in conjunction with the component.
* @param files List of files of the module. If not provided, use module.contents. * @param files List of files of the module. If not provided, use module.contents.
* @param siteId The site ID. If not defined, current site. * @param siteId The site ID. If not defined, current site.
* @param options Options to open the file.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async downloadModuleWithMainFileIfNeeded( async downloadModuleWithMainFileIfNeeded(
@ -800,6 +811,7 @@ export class CoreCourseHelperProvider {
componentId?: string | number, componentId?: string | number,
files?: CoreCourseModuleContentFile[], files?: CoreCourseModuleContentFile[],
siteId?: string, siteId?: string,
options: CoreUtilsOpenFileOptions = {},
): Promise<{ fixedUrl: string; path: string; status?: string }> { ): Promise<{ fixedUrl: string; path: string; status?: string }> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
@ -840,7 +852,17 @@ export class CoreCourseHelperProvider {
} }
if (!path) { if (!path) {
path = await this.downloadModuleWithMainFile(module, courseId, fixedUrl, files, status, component, componentId, siteId); path = await this.downloadModuleWithMainFile(
module,
courseId,
fixedUrl,
files,
status,
component,
componentId,
siteId,
options,
);
} }
return { return {
@ -862,6 +884,7 @@ export class CoreCourseHelperProvider {
* @param component The component to link the files to. * @param component The component to link the files to.
* @param componentId An ID to use in conjunction with the component. * @param componentId An ID to use in conjunction with the component.
* @param siteId The site ID. If not defined, current site. * @param siteId The site ID. If not defined, current site.
* @param options Options to open the file.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
protected async downloadModuleWithMainFile( protected async downloadModuleWithMainFile(
@ -873,6 +896,7 @@ export class CoreCourseHelperProvider {
component?: string, component?: string,
componentId?: string | number, componentId?: string | number,
siteId?: string, siteId?: string,
options: CoreUtilsOpenFileOptions = {},
): Promise<string> { ): Promise<string> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
@ -885,7 +909,7 @@ export class CoreCourseHelperProvider {
throw new CoreNetworkError(); throw new CoreNetworkError();
} }
const shouldDownloadFirst = await CoreFilepool.shouldDownloadFileBeforeOpen(fixedUrl, mainFile.filesize); const shouldDownloadFirst = await CoreFilepool.shouldDownloadFileBeforeOpen(fixedUrl, mainFile.filesize, options);
if (shouldDownloadFirst) { if (shouldDownloadFirst) {
// Download and then return the local URL. // Download and then return the local URL.

View File

@ -225,6 +225,7 @@
"percentagenumber": "{{$a}}%", "percentagenumber": "{{$a}}%",
"phone": "Phone", "phone": "Phone",
"pictureof": "Picture of {{$a}}", "pictureof": "Picture of {{$a}}",
"play": "Play",
"previous": "Previous", "previous": "Previous",
"proceed": "Proceed", "proceed": "Proceed",
"pulltorefresh": "Pull to refresh", "pulltorefresh": "Pull to refresh",

View File

@ -73,7 +73,17 @@ export class CoreFileHelperProvider {
await this.showConfirmOpenUnsupportedFile(); await this.showConfirmOpenUnsupportedFile();
} }
let url = await this.downloadFileIfNeeded(file, fileUrl, component, componentId, timemodified, state, onProgress, siteId); let url = await this.downloadFileIfNeeded(
file,
fileUrl,
component,
componentId,
timemodified,
state,
onProgress,
siteId,
options,
);
if (!url) { if (!url) {
return; return;
@ -127,6 +137,7 @@ export class CoreFileHelperProvider {
* @param state The file's state. If not provided, it will be calculated. * @param state The file's state. If not provided, it will be calculated.
* @param onProgress Function to call on progress. * @param onProgress Function to call on progress.
* @param siteId The site ID. If not defined, current site. * @param siteId The site ID. If not defined, current site.
* @param options Options to open the file.
* @return Resolved with the URL to use on success. * @return Resolved with the URL to use on success.
*/ */
protected async downloadFileIfNeeded( protected async downloadFileIfNeeded(
@ -138,6 +149,7 @@ export class CoreFileHelperProvider {
state?: string, state?: string,
onProgress?: CoreFileHelperOnProgress, onProgress?: CoreFileHelperOnProgress,
siteId?: string, siteId?: string,
options: CoreUtilsOpenFileOptions = {},
): Promise<string> { ): Promise<string> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
@ -172,7 +184,7 @@ export class CoreFileHelperProvider {
onProgress({ calculating: true }); onProgress({ calculating: true });
} }
const shouldDownloadFirst = await CoreFilepool.shouldDownloadFileBeforeOpen(fixedUrl, file.filesize || 0); const shouldDownloadFirst = await CoreFilepool.shouldDownloadFileBeforeOpen(fixedUrl, file.filesize || 0, options);
if (shouldDownloadFirst) { if (shouldDownloadFirst) {
// Download the file first. // Download the file first.
if (state == CoreConstants.DOWNLOADING) { if (state == CoreConstants.DOWNLOADING) {

View File

@ -26,7 +26,7 @@ import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time'; import { CoreTimeUtils } from '@services/utils/time';
import { CoreUrlUtils } from '@services/utils/url'; import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils, PromiseDefer } from '@services/utils/utils'; import { CoreUtils, CoreUtilsOpenFileOptions, PromiseDefer } from '@services/utils/utils';
import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDB } from '@classes/sqlitedb';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
@ -2781,7 +2781,7 @@ export class CoreFilepoolProvider {
const mimetype = await CoreUtils.getMimeTypeFromUrl(url); const mimetype = await CoreUtils.getMimeTypeFromUrl(url);
// If the file is streaming (audio or video) we reject. // If the file is streaming (audio or video) we reject.
if (mimetype.indexOf('video') != -1 || mimetype.indexOf('audio') != -1) { if (CoreMimetypeUtils.isStreamedMimetype(mimetype)) {
throw new CoreError('File is audio or video.'); throw new CoreError('File is audio or video.');
} }
} }
@ -2791,6 +2791,7 @@ export class CoreFilepoolProvider {
* *
* @param url File online URL. * @param url File online URL.
* @param size File size. * @param size File size.
* @param options Options.
* @return Promise resolved with boolean: whether file should be downloaded before opening it. * @return Promise resolved with boolean: whether file should be downloaded before opening it.
* @description * @description
* Convenience function to check if a file should be downloaded before opening it. * Convenience function to check if a file should be downloaded before opening it.
@ -2800,16 +2801,21 @@ export class CoreFilepoolProvider {
* - The file cannot be streamed. * - The file cannot be streamed.
* If the file is big and can be streamed, the promise returned by this function will be rejected. * If the file is big and can be streamed, the promise returned by this function will be rejected.
*/ */
async shouldDownloadFileBeforeOpen(url: string, size: number): Promise<boolean> { async shouldDownloadFileBeforeOpen(url: string, size: number, options: CoreUtilsOpenFileOptions = {}): Promise<boolean> {
if (size >= 0 && size <= CoreFilepoolProvider.DOWNLOAD_THRESHOLD) { if (size >= 0 && size <= CoreFilepoolProvider.DOWNLOAD_THRESHOLD) {
// The file is small, download it. // The file is small, download it.
return true; return true;
} }
if (CoreUtils.shouldOpenWithDialog(options)) {
// Open with dialog needs a local file.
return true;
}
const mimetype = await CoreUtils.getMimeTypeFromUrl(url); const mimetype = await CoreUtils.getMimeTypeFromUrl(url);
// If the file is streaming (audio or video), return false. // If the file is streaming (audio or video), return false.
return mimetype.indexOf('video') == -1 && mimetype.indexOf('audio') == -1; return !CoreMimetypeUtils.isStreamedMimetype(mimetype);
} }
/** /**

View File

@ -562,6 +562,16 @@ export class CoreMimetypeUtilsProvider {
return false; return false;
} }
/**
* Check if a mimetype belongs to a file that can be streamed (audio, video).
*
* @param mimetype Mimetype.
* @return Boolean.
*/
isStreamedMimetype(mimetype: string): boolean {
return mimetype.indexOf('video') != -1 || mimetype.indexOf('audio') != -1;
}
/** /**
* Remove the extension from a path (if any). * Remove the extension from a path (if any).
* *

View File

@ -933,8 +933,7 @@ export class CoreUtilsProvider {
} }
try { try {
const openFileAction = options.iOSOpenFileAction ?? CoreConstants.CONFIG.iOSDefaultOpenFileAction; if (this.shouldOpenWithDialog(options)) {
if (CoreApp.isIOS() && openFileAction == OpenFileAction.OPEN_WITH) {
await FileOpener.showOpenWithDialog(path, mimetype || ''); await FileOpener.showOpenWithDialog(path, mimetype || '');
} else { } else {
await FileOpener.open(path, mimetype || ''); await FileOpener.open(path, mimetype || '');
@ -1652,6 +1651,18 @@ export class CoreUtilsProvider {
return this.wait(0); return this.wait(0);
} }
/**
* Given some options, check if a file should be opened with showOpenWithDialog.
*
* @param options Options.
* @return Boolean.
*/
shouldOpenWithDialog(options: CoreUtilsOpenFileOptions = {}): boolean {
const openFileAction = options.iOSOpenFileAction ?? CoreConstants.CONFIG.iOSDefaultOpenFileAction;
return CoreApp.isIOS() && openFileAction == OpenFileAction.OPEN_WITH;
}
} }
export const CoreUtils = makeSingleton(CoreUtilsProvider); export const CoreUtils = makeSingleton(CoreUtilsProvider);