Bolts/iOS/BFWebViewAppLinkResolver.m (240 lines of code) (raw):
/*
* Copyright (c) 2014, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
*/
#import <WebKit/WebKit.h>
#import "BFWebViewAppLinkResolver.h"
#import "BFAppLink.h"
#import "BFAppLinkTarget.h"
#import "BFTask.h"
#import "BFTaskCompletionSource.h"
#import "BFExecutor.h"
// Defines JavaScript to extract app link tags from HTML content
static NSString *const BFWebViewAppLinkResolverTagExtractionJavaScript = @""
"(function() {"
" var metaTags = document.getElementsByTagName('meta');"
" var results = [];"
" for (var i = 0; i < metaTags.length; i++) {"
" var property = metaTags[i].getAttribute('property');"
" if (property && property.substring(0, 'al:'.length) === 'al:') {"
" var tag = { \"property\": metaTags[i].getAttribute('property') };"
" if (metaTags[i].hasAttribute('content')) {"
" tag['content'] = metaTags[i].getAttribute('content');"
" }"
" results.push(tag);"
" }"
" }"
" return JSON.stringify(results);"
"})()";
static NSString *const BFWebViewAppLinkResolverIOSURLKey = @"url";
static NSString *const BFWebViewAppLinkResolverIOSAppStoreIdKey = @"app_store_id";
static NSString *const BFWebViewAppLinkResolverIOSAppNameKey = @"app_name";
static NSString *const BFWebViewAppLinkResolverDictionaryValueKey = @"_value";
static NSString *const BFWebViewAppLinkResolverPreferHeader = @"Prefer-Html-Meta-Tags";
static NSString *const BFWebViewAppLinkResolverMetaTagPrefix = @"al";
static NSString *const BFWebViewAppLinkResolverWebKey = @"web";
static NSString *const BFWebViewAppLinkResolverIOSKey = @"ios";
static NSString *const BFWebViewAppLinkResolverIPhoneKey = @"iphone";
static NSString *const BFWebViewAppLinkResolverIPadKey = @"ipad";
static NSString *const BFWebViewAppLinkResolverWebURLKey = @"url";
static NSString *const BFWebViewAppLinkResolverShouldFallbackKey = @"should_fallback";
@interface BFWebViewAppLinkResolverWebViewDelegate : NSObject <WKNavigationDelegate>
@property (nonatomic, copy) void (^didFinishLoad)(WKWebView *webView);
@property (nonatomic, copy) void (^didFailLoadWithError)(WKWebView *webView, NSError *error);
@property (nonatomic, assign) BOOL hasLoaded;
@end
@implementation BFWebViewAppLinkResolverWebViewDelegate
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation {
if (self.didFinishLoad) {
self.didFinishLoad(webView);
}
}
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation
{
}
- (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error {
if (self.didFailLoadWithError) {
self.didFailLoadWithError(webView, error);
}
}
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
if (self.hasLoaded) {
// Consider loading a second resource to be "success", since it indicates an inner frame
// or redirect is happening. We can run the tag extraction script at this point.
self.didFinishLoad(webView);
}
self.hasLoaded = YES;
decisionHandler(WKNavigationActionPolicyAllow);
}
@end
@implementation BFWebViewAppLinkResolver
+ (instancetype)sharedInstance {
static id instance;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
- (BFTask *)followRedirects:(NSURL *)url {
// This task will be resolved with either the redirect NSURL
// or a dictionary with the response data to be returned.
BFTaskCompletionSource *tcs = [BFTaskCompletionSource taskCompletionSource];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
[request setValue:BFWebViewAppLinkResolverMetaTagPrefix forHTTPHeaderField:BFWebViewAppLinkResolverPreferHeader];
void (^completion)(NSURLResponse *response, NSData *data, NSError *error) = ^(NSURLResponse *response, NSData *data, NSError *error) {
if (error) {
[tcs setError:error];
return;
}
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
// NSURLConnection usually follows redirects automatically, but the
// documentation is unclear what the default is. This helps it along.
if (httpResponse.statusCode >= 300 && httpResponse.statusCode < 400) {
NSString *redirectString = httpResponse.allHeaderFields[@"Location"];
NSURL *redirectURL = [NSURL URLWithString:redirectString];
[tcs setResult:redirectURL];
return;
}
}
[tcs setResult:@{ @"response" : response, @"data" : data }];
};
#if __IPHONE_OS_VERSION_MIN_REQUIRED >= __IPHONE_7_0 || __MAC_OS_X_VERSION_MIN_REQUIRED >= __MAC_10_9
NSURLSession *session = [NSURLSession sharedSession];
[[session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
completion(response, data, error);
}] resume];
#else
[NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:completion];
#endif
return [tcs.task continueWithSuccessBlock:^id(BFTask *task) {
// If we redirected, just keep recursing.
if ([task.result isKindOfClass:[NSURL class]]) {
return [self followRedirects:task.result];
}
return task;
}];
}
- (BFTask *)appLinkFromURLInBackground:(NSURL *)url NS_EXTENSION_UNAVAILABLE_IOS("") {
return [[self followRedirects:url] continueWithExecutor:[BFExecutor mainThreadExecutor]
withSuccessBlock:^id(BFTask *task) {
NSData *responseData = task.result[@"data"];
NSHTTPURLResponse *response = task.result[@"response"];
BFTaskCompletionSource *tcs = [BFTaskCompletionSource taskCompletionSource];
WKWebView *webView = [[WKWebView alloc] init];
BFWebViewAppLinkResolverWebViewDelegate *listener = [[BFWebViewAppLinkResolverWebViewDelegate alloc] init];
__block BFWebViewAppLinkResolverWebViewDelegate *retainedListener = listener;
listener.didFinishLoad = ^(WKWebView *view) {
if (retainedListener) {
[self getALDataFromLoadedPage:view completion:^(NSDictionary *result, NSError *error) {
[view removeFromSuperview];
view.navigationDelegate = nil;
retainedListener = nil;
[tcs setResult:[self appLinkFromALData:result destination:url]];
}];
}
};
listener.didFailLoadWithError = ^(WKWebView* view, NSError *error) {
if (retainedListener) {
[view removeFromSuperview];
view.navigationDelegate = nil;
retainedListener = nil;
[tcs setError:error];
}
};
webView.navigationDelegate = listener;
webView.configuration.preferences.javaScriptEnabled = true;
webView.hidden = YES;
if (@available(iOS 9.0, *)) {
[webView loadData:responseData
MIMEType:response.MIMEType
characterEncodingName:response.textEncodingName
baseURL:response.URL];
UIWindow *window = [UIApplication sharedApplication].windows.firstObject;
[window addSubview:webView];
} else {
// Fallback on earlier versions
}
return tcs.task;
}];
}
/*
Builds up a data structure filled with the app link data from the meta tags on a page.
The structure of this object is a dictionary where each key holds an array of app link
data dictionaries. Values are stored in a key called "_value".
*/
- (NSDictionary *)parseALData:(NSArray *)dataArray {
NSMutableDictionary *al = [NSMutableDictionary dictionary];
for (NSDictionary *tag in dataArray) {
NSString *name = tag[@"property"];
if (![name isKindOfClass:[NSString class]]) {
continue;
}
NSArray *nameComponents = [name componentsSeparatedByString:@":"];
if (![nameComponents[0] isEqualToString:BFWebViewAppLinkResolverMetaTagPrefix]) {
continue;
}
NSMutableDictionary *root = al;
for (NSUInteger i = 1; i < nameComponents.count; i++) {
NSMutableArray *children = root[nameComponents[i]];
if (!children) {
children = [NSMutableArray array];
root[nameComponents[i]] = children;
}
NSMutableDictionary *child = children.lastObject;
if (!child || i == nameComponents.count - 1) {
child = [NSMutableDictionary dictionary];
[children addObject:child];
}
root = child;
}
if (tag[@"content"]) {
root[BFWebViewAppLinkResolverDictionaryValueKey] = tag[@"content"];
}
}
return al;
}
- (void)getALDataFromLoadedPage:(WKWebView *)webView completion:(void (^ _Nullable)(NSDictionary * _Nullable, NSError * _Nullable error))completionHandler {
[webView evaluateJavaScript:BFWebViewAppLinkResolverTagExtractionJavaScript completionHandler:^(id result, NSError * error) {
if (error == nil) {
NSString *jsonString = result;
NSError *parseError = nil;
NSArray *arr = [NSJSONSerialization JSONObjectWithData:[jsonString dataUsingEncoding:NSUTF8StringEncoding]
options:0
error:&parseError];
completionHandler([self parseALData:arr], parseError);
} else {
completionHandler(nil, error);
}
}];
}
/*
Converts app link data into a BFAppLink containing the targets relevant for this platform.
*/
- (BFAppLink *)appLinkFromALData:(NSDictionary *)appLinkDict destination:(NSURL *)destination {
NSMutableArray *linkTargets = [NSMutableArray array];
NSArray *platformData = nil;
const UIUserInterfaceIdiom idiom = UI_USER_INTERFACE_IDIOM();
if (idiom == UIUserInterfaceIdiomPad) {
platformData = @[ appLinkDict[BFWebViewAppLinkResolverIPadKey] ?: @{},
appLinkDict[BFWebViewAppLinkResolverIOSKey] ?: @{} ];
} else if (idiom == UIUserInterfaceIdiomPhone) {
platformData = @[ appLinkDict[BFWebViewAppLinkResolverIPhoneKey] ?: @{},
appLinkDict[BFWebViewAppLinkResolverIOSKey] ?: @{} ];
} else {
// Future-proofing. Other User Interface idioms should only hit ios.
platformData = @[ appLinkDict[BFWebViewAppLinkResolverIOSKey] ?: @{} ];
}
for (NSArray *platformObjects in platformData) {
for (NSDictionary *platformDict in platformObjects) {
// The schema requires a single url/app store id/app name,
// but we could find multiple of them. We'll make a best effort
// to interpret this data.
NSArray *urls = platformDict[BFWebViewAppLinkResolverIOSURLKey];
NSArray *appStoreIds = platformDict[BFWebViewAppLinkResolverIOSAppStoreIdKey];
NSArray *appNames = platformDict[BFWebViewAppLinkResolverIOSAppNameKey];
NSUInteger maxCount = MAX(urls.count, MAX(appStoreIds.count, appNames.count));
for (NSUInteger i = 0; i < maxCount; i++) {
NSString *urlString = urls[i][BFWebViewAppLinkResolverDictionaryValueKey];
NSURL *url = urlString ? [NSURL URLWithString:urlString] : nil;
NSString *appStoreId = appStoreIds[i][BFWebViewAppLinkResolverDictionaryValueKey];
NSString *appName = appNames[i][BFWebViewAppLinkResolverDictionaryValueKey];
BFAppLinkTarget *target = [BFAppLinkTarget appLinkTargetWithURL:url
appStoreId:appStoreId
appName:appName];
[linkTargets addObject:target];
}
}
}
NSDictionary *webDict = appLinkDict[BFWebViewAppLinkResolverWebKey][0];
NSString *webUrlString = webDict[BFWebViewAppLinkResolverWebURLKey][0][BFWebViewAppLinkResolverDictionaryValueKey];
NSString *shouldFallbackString = webDict[BFWebViewAppLinkResolverShouldFallbackKey][0][BFWebViewAppLinkResolverDictionaryValueKey];
NSURL *webUrl = destination;
if (shouldFallbackString &&
[@[ @"no", @"false", @"0" ] containsObject:[shouldFallbackString lowercaseString]]) {
webUrl = nil;
}
if (webUrl && webUrlString) {
webUrl = [NSURL URLWithString:webUrlString];
}
return [BFAppLink appLinkWithSourceURL:destination
targets:linkTargets
webURL:webUrl];
}
@end