blob: c93bffe1ee921cd941d6945e1e33bc9d03c740cf [file] [log] [blame]
// Copyright 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/web/external_app_launcher.h"
#include "base/feature_list.h"
#include "base/ios/ios_util.h"
#include "base/logging.h"
#include "base/mac/foundation_util.h"
#include "base/metrics/histogram_macros.h"
#include "base/strings/sys_string_conversions.h"
#include "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/open_url_util.h"
#import "ios/chrome/browser/ui/external_app/open_mail_handler_view_controller.h"
#import "ios/chrome/browser/web/external_apps_launch_policy_decider.h"
#import "ios/chrome/browser/web/mailto_handler.h"
#import "ios/chrome/browser/web/mailto_url_rewriter.h"
#import "ios/chrome/browser/web/nullable_mailto_url_rewriter.h"
#include "ios/chrome/grit/ios_strings.h"
#include "ios/third_party/material_components_ios/src/components/BottomSheet/src/MDCBottomSheetController.h"
#import "net/base/mac/url_conversions.h"
#include "ui/base/l10n/l10n_util.h"
#include "url/gurl.h"
#include "url/url_constants.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
// Returns a set of NSStrings that are URL schemes for iTunes Stores.
NSSet<NSString*>* ITMSSchemes() {
static NSSet<NSString*>* schemes;
static dispatch_once_t once;
dispatch_once(&once, ^{
schemes = [NSSet<NSString*>
setWithObjects:@"itms", @"itmss", @"itms-apps", @"itms-appss",
// There's no evidence that itms-bookss is actually
// supported, but over-inclusion costs less than
// under-inclusion.
@"itms-books", @"itms-bookss", nil];
});
return schemes;
}
bool UrlHasAppStoreScheme(const GURL& gURL) {
std::string scheme = gURL.scheme();
return [ITMSSchemes() containsObject:base::SysUTF8ToNSString(scheme)];
}
// Returns whether gURL has the scheme of a URL that initiates a call.
bool UrlHasPhoneCallScheme(const GURL& gURL) {
return gURL.SchemeIs("tel") || gURL.SchemeIs("facetime") ||
gURL.SchemeIs("facetime-audio");
}
// Returns a string to be used as the label for the prompt's action button.
NSString* PromptActionString(NSString* scheme) {
if ([scheme isEqualToString:@"facetime"])
return l10n_util::GetNSString(IDS_IOS_FACETIME_BUTTON);
else if ([scheme isEqualToString:@"tel"] ||
[scheme isEqualToString:@"facetime-audio"])
return l10n_util::GetNSString(IDS_IOS_PHONE_CALL_BUTTON);
NOTREACHED();
return @"";
}
// Launches the mail client app represented by |handler| and records metrics.
void LaunchMailClientApp(const GURL& URL, MailtoHandler* handler) {
NSString* launchURL = [handler rewriteMailtoURL:URL];
UMA_HISTOGRAM_BOOLEAN("IOS.MailtoURLRewritten", launchURL != nil);
NSURL* URLToOpen = [launchURL length] ? [NSURL URLWithString:launchURL]
: net::NSURLWithGURL(URL);
[[UIApplication sharedApplication] openURL:URLToOpen];
}
} // namespace
@interface ExternalAppLauncher ()
// Returns the Phone/FaceTime call argument from |URL|.
+ (NSString*)formatCallArgument:(NSURL*)URL;
// Returns whether there is a prompt shown by |requestToOpenURL| or not.
@property(nonatomic, getter=isPromptActive) BOOL promptActive;
// Used to check for repeated launches and provide policy for launching apps.
@property(nonatomic, strong) ExternalAppsLaunchPolicyDecider* policyDecider;
// Shows a prompt in Material Design for the user to choose which mail client
// app to use to handle a mailto:// URL.
- (void)promptForMailClientWithURL:(const GURL&)URL
URLRewriter:(MailtoURLRewriter*)rewriter;
// Presents an alert controller with |prompt| and |openLabel| as button label
// on the root view controller before launching an external app identified by
// |URL|.
- (void)openExternalAppWithURL:(NSURL*)URL
prompt:(NSString*)prompt
openLabel:(NSString*)openLabel;
// Opens URL in an external application if possible (optionally after
// confirming via dialog in case that user didn't interact using
// |linkClicked| or if the external application is face time) or returns NO
// if there is no such application available.
- (BOOL)openURL:(const GURL&)gURL linkClicked:(BOOL)linkClicked;
// Presents an alert controller on the root view controller with |prompt| as
// body text, |accept label| and |reject label| as button labels, and
// a non null |responseHandler| that takes a boolean to handle user response.
- (void)showExternalAppLauncherPrompt:(NSString*)prompt
acceptLabel:(NSString*)acceptLabel
rejectLabel:(NSString*)rejectLabel
responseHandler:(void (^_Nonnull)(BOOL))responseHandler;
@end
@implementation ExternalAppLauncher
@synthesize promptActive = _promptActive;
@synthesize policyDecider = _policyDecider;
+ (NSString*)formatCallArgument:(NSURL*)URL {
NSCharacterSet* charSet =
[NSCharacterSet characterSetWithCharactersInString:@"/"];
NSString* scheme = [NSString stringWithFormat:@"%@:", [URL scheme]];
NSString* URLString = [URL absoluteString];
if ([URLString length] <= [scheme length])
return URLString;
NSString* prompt = [[[[URL absoluteString] substringFromIndex:[scheme length]]
stringByTrimmingCharactersInSet:charSet] stringByRemovingPercentEncoding];
// Returns original URL string if there's nothing interesting to display
// other than the scheme itself.
if (![prompt length])
return URLString;
return prompt;
}
- (instancetype)init {
self = [super init];
if (self) {
_policyDecider = [[ExternalAppsLaunchPolicyDecider alloc] init];
}
return self;
}
- (void)promptForMailClientWithURL:(const GURL&)URL
URLRewriter:(MailtoURLRewriter*)rewriter {
GURL copiedURLToOpen = URL;
OpenMailHandlerViewController* mailHandlerChooser =
[[OpenMailHandlerViewController alloc]
initWithRewriter:rewriter
selectedHandler:^(MailtoHandler* _Nonnull handler) {
LaunchMailClientApp(copiedURLToOpen, handler);
}];
MDCBottomSheetController* bottomSheet = [[MDCBottomSheetController alloc]
initWithContentViewController:mailHandlerChooser];
[[[[UIApplication sharedApplication] keyWindow] rootViewController]
presentViewController:bottomSheet
animated:YES
completion:nil];
}
- (void)showExternalAppLauncherPrompt:(NSString*)prompt
acceptLabel:(NSString*)acceptLabel
rejectLabel:(NSString*)rejectLabel
responseHandler:(void (^_Nonnull)(BOOL))responseHandler {
UIAlertController* alertController =
[UIAlertController alertControllerWithTitle:nil
message:prompt
preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction* acceptAction =
[UIAlertAction actionWithTitle:acceptLabel
style:UIAlertActionStyleDefault
handler:^(UIAlertAction* action) {
responseHandler(YES);
}];
UIAlertAction* rejectAction =
[UIAlertAction actionWithTitle:rejectLabel
style:UIAlertActionStyleCancel
handler:^(UIAlertAction* action) {
responseHandler(NO);
}];
[alertController addAction:rejectAction];
[alertController addAction:acceptAction];
[[[[UIApplication sharedApplication] keyWindow] rootViewController]
presentViewController:alertController
animated:YES
completion:nil];
}
- (void)openExternalAppWithURL:(NSURL*)URL
prompt:(NSString*)prompt
openLabel:(NSString*)openLabel {
[self showExternalAppLauncherPrompt:prompt
acceptLabel:openLabel
rejectLabel:l10n_util::GetNSString(IDS_CANCEL)
responseHandler:^(BOOL accept) {
if (accept)
OpenUrlWithCompletionHandler(URL, nil);
UMA_HISTOGRAM_BOOLEAN("Tab.ExternalApplicationOpened",
accept);
}];
}
- (BOOL)requestToOpenURL:(const GURL&)gURL
sourcePageURL:(const GURL&)sourcePageURL
linkClicked:(BOOL)linkClicked {
if (!gURL.is_valid() || !gURL.has_scheme())
return NO;
// Don't open external application if chrome is not active.
if ([[UIApplication sharedApplication] applicationState] !=
UIApplicationStateActive) {
return NO;
}
// Don't try to open external application if a prompt is already active.
if (_promptActive)
return NO;
[_policyDecider didRequestLaunchExternalAppURL:gURL
fromSourcePageURL:sourcePageURL];
ExternalAppLaunchPolicy policy =
[_policyDecider launchPolicyForURL:gURL fromSourcePageURL:sourcePageURL];
switch (policy) {
case ExternalAppLaunchPolicyBlock: {
return NO;
}
case ExternalAppLaunchPolicyAllow: {
return [self openURL:gURL linkClicked:linkClicked];
}
case ExternalAppLaunchPolicyPrompt: {
__weak ExternalAppLauncher* weakSelf = self;
GURL appURL = gURL;
GURL sourceURL = sourcePageURL;
_promptActive = YES;
NSString* promptBody =
l10n_util::GetNSString(IDS_IOS_OPEN_REPEATEDLY_ANOTHER_APP);
NSString* allowLabel =
l10n_util::GetNSString(IDS_IOS_OPEN_REPEATEDLY_ANOTHER_APP_ALLOW);
NSString* blockLabel =
l10n_util::GetNSString(IDS_IOS_OPEN_REPEATEDLY_ANOTHER_APP_BLOCK);
[self
showExternalAppLauncherPrompt:promptBody
acceptLabel:allowLabel
rejectLabel:blockLabel
responseHandler:^(BOOL allowed) {
ExternalAppLauncher* strongSelf = weakSelf;
if (!strongSelf)
return;
if (allowed) {
// By confirming that user want to launch the
// application, there is no need to check for
// |linkClicked|.
[strongSelf openURL:appURL linkClicked:YES];
} else {
// TODO(crbug.com/674649): Once non modal
// dialogs are implemented, update this to
// always prompt instead of blocking the app.
[strongSelf.policyDecider
blockLaunchingAppURL:appURL
fromSourcePageURL:sourceURL];
}
UMA_HISTOGRAM_BOOLEAN(
"IOS.RepeatedExternalAppPromptResponse", allowed);
strongSelf.promptActive = NO;
}];
return YES;
}
}
}
- (BOOL)openURL:(const GURL&)gURL linkClicked:(BOOL)linkClicked {
// Don't open external application if chrome is not active.
if ([[UIApplication sharedApplication] applicationState] !=
UIApplicationStateActive) {
return NO;
}
NSURL* URL = net::NSURLWithGURL(gURL);
if (base::ios::IsRunningOnOrLater(10, 3, 0)) {
if (UrlHasAppStoreScheme(gURL)) {
NSString* prompt = l10n_util::GetNSString(IDS_IOS_OPEN_IN_ANOTHER_APP);
NSString* openLabel =
l10n_util::GetNSString(IDS_IOS_APP_LAUNCHER_OPEN_APP_BUTTON_LABEL);
[self openExternalAppWithURL:URL prompt:prompt openLabel:openLabel];
return YES;
}
} else {
// Prior to iOS 10.3, iOS does not prompt user when facetime: and
// facetime-audio: URL schemes are opened, so Chrome needs to present an
// alert before placing a phone call.
if (UrlHasPhoneCallScheme(gURL)) {
[self openExternalAppWithURL:URL
prompt:[[self class] formatCallArgument:URL]
openLabel:PromptActionString([URL scheme])];
return YES;
}
// Prior to iOS 10.3, Chrome prompts user with an alert before opening
// App Store when user did not tap on any links and an iTunes app URL is
// opened. This maintains parity with Safari in pre-10.3 environment.
if (!linkClicked && UrlHasAppStoreScheme(gURL)) {
NSString* prompt = l10n_util::GetNSString(IDS_IOS_OPEN_IN_ANOTHER_APP);
NSString* openLabel =
l10n_util::GetNSString(IDS_IOS_APP_LAUNCHER_OPEN_APP_BUTTON_LABEL);
[self openExternalAppWithURL:URL prompt:prompt openLabel:openLabel];
return YES;
}
}
// Replaces |URL| with a rewritten URL if it is of mailto: scheme.
if (gURL.SchemeIs(url::kMailToScheme)) {
MailtoURLRewriter* rewriter =
[NullableMailtoURLRewriter mailtoURLRewriterWithStandardHandlers];
NSString* handlerID = [rewriter defaultHandlerID];
if (!handlerID) {
[self promptForMailClientWithURL:gURL URLRewriter:rewriter];
return YES;
}
MailtoHandler* handler = [rewriter defaultHandlerByID:handlerID];
LaunchMailClientApp(gURL, handler);
return YES;
}
// If the following call returns YES, an external application is about to be
// launched and Chrome will go into the background now.
// TODO(crbug.com/622735): This call still needs to be updated.
// It's heavily nested so some refactoring is needed.
return [[UIApplication sharedApplication] openURL:URL];
}
@end