2024-11-18 14:24:18 +01:00

434 lines
16 KiB
TypeScript

// (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 { InAppBrowserObject, InAppBrowserOptions } from '@awesome-cordova-plugins/in-app-browser';
import { CoreErrorWithOptions } from '@classes/errors/errorwithoptions';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { CoreFilepool } from '@services/filepool';
import { CoreLang, CoreLangFormat } from '@services/lang';
import { CorePlatform } from '@services/platform';
import { CoreSites } from '@services/sites';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { Translate, FileOpener, WebIntent, InAppBrowser, NgZone } from '@singletons';
import { CoreConstants } from '../constants';
import { CoreFile } from '@services/file';
import { CorePromiseUtils } from './promise-utils';
import { CoreUrl } from './url';
import { CoreLogger } from './logger';
import { CoreConfig } from '@services/config';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreEvents } from '@singletons/events';
import { CoreColors } from './colors';
/**
* Singleton with helper functions to handler open files and urls.
*/
export class CoreOpener {
protected static logger = CoreLogger.getInstance('CoreOpener');
// Avoid creating singleton instances.
private constructor() {
// Nothing to do.
}
/**
* Show a confirm before opening a link in browser, unless the user previously marked to not show again.
*
* @param url URL to open.
*/
protected static async confirmOpenBrowserIfNeeded(url: string): Promise<void> {
if (!CoreUrl.isHttpURL(url)) {
// Only ask confirm for http(s), other cases usually launch external apps.
return;
}
// Check if the user decided not to see the warning.
const dontShowWarning = await CoreConfig.get(CoreConstants.SETTINGS_DONT_SHOW_EXTERNAL_LINK_WARN, 0);
if (dontShowWarning) {
return;
}
// Remove common sensitive information from the URL.
url = url
.replace(/token=[^&#]+/gi, 'token=secret')
.replace(/tokenpluginfile\.php\/[^/]+/gi, 'tokenpluginfile.php/secret');
const dontShowAgain = await CoreDomUtils.showPrompt(
Translate.instant('core.warnopeninbrowser', { url }),
undefined,
Translate.instant('core.dontshowagain'),
'checkbox',
);
if (dontShowAgain) {
CoreConfig.set(CoreConstants.SETTINGS_DONT_SHOW_EXTERNAL_LINK_WARN, 1);
}
}
/**
* Open a file using platform specific method.
*
* @param path The local path of the file to be open.
* @param options Options.
* @returns Promise resolved when done.
*/
static async openFile(path: string, options: CoreOpenerOpenFileOptions = {}): Promise<void> {
// Convert the path to a native path if needed.
path = CoreFile.unconvertFileSrc(path);
const extension = CoreMimetypeUtils.getFileExtension(path);
const mimetype = extension && CoreMimetypeUtils.getMimeType(extension);
if (mimetype == 'text/html' && CorePlatform.isAndroid()) {
// Open HTML local files in InAppBrowser, in system browser some embedded files aren't loaded.
CoreOpener.openInApp(path);
return;
} else if (extension === 'apk' && CorePlatform.isAndroid()) {
const url = await CorePromiseUtils.ignoreErrors(
CoreFilepool.getFileUrlByPath(CoreSites.getCurrentSiteId(), CoreFile.removeBasePath(path)),
);
// @todo MOBILE-4167: Handle urls with expired tokens.
throw new CoreErrorWithOptions(
Translate.instant('core.cannotinstallapkinfo'),
Translate.instant('core.cannotinstallapk'),
url
? [
{
text: Translate.instant('core.openinbrowser'),
handler: () => CoreOpener.openInBrowser(url),
},
{
text: Translate.instant('core.cancel'),
role: 'cancel',
},
]
: undefined,
);
}
// Path needs to be decoded, the file won't be opened if the path has %20 instead of spaces and so.
try {
path = decodeURIComponent(path);
} catch {
// Error, use the original path.
}
const openFile = async (mimetype?: string) => {
if (CoreOpener.shouldOpenWithDialog(options)) {
await FileOpener.showOpenWithDialog(path, mimetype || '');
} else {
await FileOpener.open(path, mimetype || '');
}
};
try {
try {
await openFile(mimetype);
} catch (error) {
if (!extension || !error || Number(error.status) !== 9) {
throw error;
}
// Cannot open mimetype. Check if there is a deprecated mimetype for the extension.
const deprecatedMimetype = CoreMimetypeUtils.getDeprecatedMimeType(extension);
if (!deprecatedMimetype || deprecatedMimetype === mimetype) {
throw error;
}
await openFile(deprecatedMimetype);
}
} catch (error) {
CoreOpener.logger.error('Error opening file ' + path + ' with mimetype ' + mimetype);
CoreOpener.logger.error('Error: ', JSON.stringify(error));
if (!extension || extension.indexOf('/') > -1 || extension.indexOf('\\') > -1) {
// Extension not found.
throw new Error(Translate.instant('core.erroropenfilenoextension'));
}
throw new Error(Translate.instant('core.erroropenfilenoapp'));
}
}
/**
* Open a URL using a browser.
* Do not use for files, refer to {@link CoreOpener.openFile}.
*
* @param url The URL to open.
* @param options Options.
*/
static async openInBrowser(url: string, options: CoreOpenerOpenInBrowserOptions = {}): Promise<void> {
// eslint-disable-next-line deprecation/deprecation
const originaUrl = CoreUrl.unfixPluginfileURL(options.originalUrl ?? options.browserWarningUrl ?? url);
if (options.showBrowserWarning || options.showBrowserWarning === undefined) {
try {
await CoreOpener.confirmOpenBrowserIfNeeded(originaUrl);
} catch {
// Cancelled, stop.
return;
}
}
const site = CoreSites.getCurrentSite();
CoreAnalytics.logEvent({ type: CoreAnalyticsEventType.OPEN_LINK, link: originaUrl });
window.open(
site?.containsUrl(url)
? CoreUrl.addParamsToUrl(url, { lang: await CoreLang.getCurrentLanguage(CoreLangFormat.LMS) })
: url,
'_system',
);
}
/**
* Open an online file using platform specific method.
* Specially useful for audio and video since they can be streamed.
*
* @param url The URL of the file.
* @returns Promise resolved when opened.
*/
static async openOnlineFile(url: string): Promise<void> {
if (CorePlatform.isAndroid()) {
// In Android we need the mimetype to open it.
const mimetype = await CorePromiseUtils.ignoreErrors(CoreMimetypeUtils.getMimeTypeFromUrl(url));
if (!mimetype) {
// Couldn't retrieve mimetype. Return error.
throw new Error(Translate.instant('core.erroropenfilenoextension'));
}
const options = {
action: WebIntent.ACTION_VIEW,
url,
type: mimetype,
};
try {
await WebIntent.startActivity(options);
CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.OPEN_LINK,
link: CoreUrl.unfixPluginfileURL(url),
});
return;
} catch (error) {
CoreOpener.logger.error('Error opening online file ' + url + ' with mimetype ' + mimetype);
CoreOpener.logger.error('Error: ', JSON.stringify(error));
throw new Error(Translate.instant('core.erroropenfilenoapp'));
}
}
// In the rest of platforms we need to open them in InAppBrowser.
CoreOpener.openInApp(url);
}
/**
* Given some options, check if a file should be opened with showOpenWithDialog.
*
* @param options Options.
* @returns Boolean.
*/
static shouldOpenWithDialog(options: CoreOpenerOpenFileOptions = {}): boolean {
const openFileAction = options.iOSOpenFileAction ?? CoreConstants.CONFIG.iOSDefaultOpenFileAction;
return CorePlatform.isIOS() && openFileAction == OpenFileAction.OPEN_WITH;
}
private static iabInstance?: InAppBrowserObject;
/**
* Close the InAppBrowser window.
*/
static closeInAppBrowser(): void {
if (!CoreOpener.iabInstance) {
return;
}
CoreOpener.iabInstance.close();
}
/**
* Get inapp browser instance (if any).
*
* @returns IAB instance, undefined if not open.
*/
static getInAppBrowserInstance(): InAppBrowserObject | undefined {
return CoreOpener.iabInstance;
}
/**
* Check if inapp browser is open.
*
* @returns Whether it's open.
*/
static isInAppBrowserOpen(): boolean {
return !!CoreOpener.iabInstance;
}
/**
* Open a URL using InAppBrowser.
* Do not use for files, refer to CoreOpener.openFile.
*
* @param url The URL to open.
* @param options Override default options passed to InAppBrowser.
* @returns The opened window.
*/
static openInApp(url: string, options?: CoreOpenerOpenInAppBrowserOptions): InAppBrowserObject {
options = options || {};
options.usewkwebview = 'yes'; // Force WKWebView in iOS.
options.enableViewPortScale = options.enableViewPortScale ?? 'yes'; // Enable zoom on iOS by default.
options.allowInlineMediaPlayback = options.allowInlineMediaPlayback ?? 'yes'; // Allow playing inline videos in iOS.
if (!options.location && CorePlatform.isIOS() && url.indexOf('file://') === 0) {
// The URL uses file protocol, don't show it on iOS.
// In Android we keep it because otherwise we lose the whole toolbar.
options.location = 'no';
}
CoreOpener.setInAppBrowserToolbarColors(options);
CoreOpener.iabInstance = InAppBrowser.create(url, '_blank', options);
if (CorePlatform.isMobile()) {
const loadStartUrls: string[] = [];
const loadStartSubscription = CoreOpener.iabInstance.on('loadstart').subscribe((event) => {
NgZone.run(() => {
// Store the last loaded URLs (max 10).
loadStartUrls.push(event.url);
if (loadStartUrls.length > 10) {
loadStartUrls.shift();
}
CoreEvents.trigger(CoreEvents.IAB_LOAD_START, event);
});
});
const loadStopSubscription = CoreOpener.iabInstance.on('loadstop').subscribe((event) => {
NgZone.run(() => {
CoreEvents.trigger(CoreEvents.IAB_LOAD_STOP, event);
});
});
const messageSubscription = CoreOpener.iabInstance.on('message').subscribe((event) => {
NgZone.run(() => {
CoreEvents.trigger(CoreEvents.IAB_MESSAGE, event.data);
});
});
const exitSubscription = CoreOpener.iabInstance.on('exit').subscribe((event) => {
NgZone.run(() => {
loadStartSubscription.unsubscribe();
loadStopSubscription.unsubscribe();
messageSubscription.unsubscribe();
exitSubscription.unsubscribe();
CoreOpener.iabInstance = undefined;
CoreEvents.trigger(CoreEvents.IAB_EXIT, event);
});
});
}
CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.OPEN_LINK,
link: CoreUrl.unfixPluginfileURL(options.originalUrl ?? url),
});
return CoreOpener.iabInstance;
}
/**
* Given some IAB options, set the toolbar colors properties to the right values.
*
* @param options Options to change.
* @returns Changed options.
*/
protected static setInAppBrowserToolbarColors(options: InAppBrowserOptions): InAppBrowserOptions {
if (options.toolbarcolor) {
// Color already set.
return options;
}
// Color not set. Check if it needs to be changed automatically.
let bgColor: string | undefined;
let textColor: string | undefined;
if (CoreConstants.CONFIG.iabToolbarColors === 'auto') {
bgColor = CoreColors.getToolbarBackgroundColor();
} else if (CoreConstants.CONFIG.iabToolbarColors && typeof CoreConstants.CONFIG.iabToolbarColors === 'object') {
bgColor = CoreConstants.CONFIG.iabToolbarColors.background;
textColor = CoreConstants.CONFIG.iabToolbarColors.text;
}
if (!bgColor) {
// Use default color. In iOS, use black background color since the default is transparent and doesn't look good.
options.locationcolor = '#000000';
return options;
}
if (!textColor) {
textColor = CoreColors.isWhiteContrastingBetter(bgColor) ? '#ffffff' : '#000000';
}
options.toolbarcolor = bgColor;
options.closebuttoncolor = textColor;
options.navigationbuttoncolor = textColor;
options.locationcolor = bgColor;
options.locationtextcolor = textColor;
return options;
}
}
/**
* Options for opening in InAppBrowser.
*/
export type CoreOpenerOpenInAppBrowserOptions = InAppBrowserOptions & {
originalUrl?: string; // Original URL to open (in case the URL was treated, e.g. to add a token or an auto-login).
};
/**
* Options for opening a file.
*/
export type CoreOpenerOpenFileOptions = {
iOSOpenFileAction?: OpenFileAction; // Action to do when opening a file.
};
/**
* Options for opening in browser.
*/
export type CoreOpenerOpenInBrowserOptions = {
showBrowserWarning?: boolean; // Whether to display a warning before opening in browser. Defaults to true.
originalUrl?: string; // Original URL to open (in case the URL was treated, e.g. to add a token or an auto-login).
/**
* @deprecated since 4.3. Use originalUrl instead.
*/
browserWarningUrl?: string;
};
/**
* Possible default picker actions.
*/
export enum OpenFileAction {
OPEN = 'open',
OPEN_WITH = 'open-with',
}