/* * 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. * */ package bolts; import android.content.Context; import android.net.Uri; import android.webkit.JavascriptInterface; import android.webkit.WebView; import android.webkit.WebViewClient; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; /** * A reference implementation for an App Link resolver that uses a hidden * {@link android.webkit.WebView} to parse the HTML containing App Link metadata. */ public class WebViewAppLinkResolver implements AppLinkResolver { private final Context context; /** * Creates a WebViewAppLinkResolver. * * @param context the context in which to create the hidden {@link android.webkit.WebView}. */ public WebViewAppLinkResolver(Context context) { this.context = context; } private static final String TAG_EXTRACTION_JAVASCRIPT = "javascript:" + "boltsWebViewAppLinkResolverResult.setValue((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);" + "})())"; private static final String PREFER_HEADER = "Prefer-Html-Meta-Tags"; private static final String META_TAG_PREFIX = "al"; private static final String KEY_AL_VALUE = "value"; private static final String KEY_APP_NAME = "app_name"; private static final String KEY_CLASS = "class"; private static final String KEY_PACKAGE = "package"; private static final String KEY_URL = "url"; private static final String KEY_SHOULD_FALLBACK = "should_fallback"; private static final String KEY_WEB_URL = "url"; private static final String KEY_WEB = "web"; private static final String KEY_ANDROID = "android"; @Override public Task<AppLink> getAppLinkFromUrlInBackground(final Uri url) { final Capture<String> content = new Capture<String>(); final Capture<String> contentType = new Capture<String>(); return Task.callInBackground(new Callable<Void>() { @Override public Void call() throws Exception { URL currentURL = new URL(url.toString()); URLConnection connection = null; while (currentURL != null) { // Fetch the content at the given URL. connection = currentURL.openConnection(); if (connection instanceof HttpURLConnection) { // Unfortunately, this doesn't actually follow redirects if they go from http->https, // so we have to do that manually. ((HttpURLConnection) connection).setInstanceFollowRedirects(true); } connection.setRequestProperty(PREFER_HEADER, META_TAG_PREFIX); connection.connect(); if (connection instanceof HttpURLConnection) { HttpURLConnection httpConnection = (HttpURLConnection) connection; if (httpConnection.getResponseCode() >= 300 && httpConnection.getResponseCode() < 400) { currentURL = new URL(httpConnection.getHeaderField("Location")); httpConnection.disconnect(); } else { currentURL = null; } } else { currentURL = null; } } try { content.set(readFromConnection(connection)); contentType.set(connection.getContentType()); } finally { if (connection instanceof HttpURLConnection) { ((HttpURLConnection) connection).disconnect(); } } return null; } }).onSuccessTask(new Continuation<Void, Task<JSONArray>>() { @Override public Task<JSONArray> then(Task<Void> task) throws Exception { // Load the content in a WebView and use JavaScript to extract the meta tags. final Task<JSONArray>.TaskCompletionSource tcs = Task.create(); final WebView webView = new WebView(context); webView.getSettings().setJavaScriptEnabled(true); webView.setNetworkAvailable(false); webView.setWebViewClient(new WebViewClient() { private boolean loaded = false; private void runJavaScript(WebView view) { if (!loaded) { // After the first resource has been loaded (which will be the pre-populated data) // run the JavaScript meta tag extraction script loaded = true; view.loadUrl(TAG_EXTRACTION_JAVASCRIPT); } } @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); runJavaScript(view); } @Override public void onLoadResource(WebView view, String url) { super.onLoadResource(view, url); runJavaScript(view); } }); // Inject an object that will receive the JSON for the extracted JavaScript tags webView.addJavascriptInterface(new Object() { @JavascriptInterface public void setValue(String value) { try { tcs.trySetResult(new JSONArray(value)); } catch (JSONException e) { tcs.trySetError(e); } } }, "boltsWebViewAppLinkResolverResult"); String inferredContentType = null; if (contentType.get() != null) { inferredContentType = contentType.get().split(";")[0]; } webView.loadDataWithBaseURL(url.toString(), content.get(), inferredContentType, null, null); return tcs.getTask(); } }, Task.UI_THREAD_EXECUTOR).onSuccess(new Continuation<JSONArray, AppLink>() { @Override public AppLink then(Task<JSONArray> task) throws Exception { Map<String, Object> alData = parseAlData(task.getResult()); AppLink appLink = makeAppLinkFromAlData(alData, url); return appLink; } }); } /** * 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". */ private static Map<String, Object> parseAlData(JSONArray dataArray) throws JSONException { HashMap<String, Object> al = new HashMap<String, Object>(); for (int i = 0; i < dataArray.length(); i++) { JSONObject tag = dataArray.getJSONObject(i); String name = tag.getString("property"); String[] nameComponents = name.split(":"); if (!nameComponents[0].equals(META_TAG_PREFIX)) { continue; } Map<String, Object> root = al; for (int j = 1; j < nameComponents.length; j++) { @SuppressWarnings("unchecked") List<Map<String, Object>> children = (List<Map<String, Object>>) root.get(nameComponents[j]); if (children == null) { children = new ArrayList<Map<String, Object>>(); root.put(nameComponents[j], children); } Map<String, Object> child = children.size() > 0 ? children.get(children.size() - 1) : null; if (child == null || j == nameComponents.length - 1) { child = new HashMap<String, Object>(); children.add(child); } root = child; } if (tag.has("content")) { if (tag.isNull("content")) { root.put(KEY_AL_VALUE, null); } else { root.put(KEY_AL_VALUE, tag.getString("content")); } } } return al; } @SuppressWarnings("unchecked") private static List<Map<String, Object>> getAlList(Map<String, Object> map, String key) { List<Map<String, Object>> result = (List<Map<String, Object>>) map.get(key); if (result == null) { return Collections.emptyList(); } return result; } @SuppressWarnings("unchecked") private static AppLink makeAppLinkFromAlData(Map<String, Object> appLinkDict, Uri destination) { List<AppLink.Target> targets = new ArrayList<AppLink.Target>(); List<Map<String, Object>> platformMapList = (List<Map<String, Object>>) appLinkDict.get(KEY_ANDROID); if (platformMapList == null) { platformMapList = Collections.emptyList(); } for (Map<String, Object> platformMap : platformMapList) { // The schema requires a single url/package/app name/class, but we could find multiple // of them. We'll make a best effort to interpret this data. List<Map<String, Object>> urls = getAlList(platformMap, KEY_URL); List<Map<String, Object>> packages = getAlList(platformMap, KEY_PACKAGE); List<Map<String, Object>> classes = getAlList(platformMap, KEY_CLASS); List<Map<String, Object>> appNames = getAlList(platformMap, KEY_APP_NAME); int maxCount = Math.max(urls.size(), Math.max(packages.size(), Math.max(classes.size(), appNames.size()))); for (int i = 0; i < maxCount; i++) { String urlString = (String) (urls.size() > i ? urls.get(i).get(KEY_AL_VALUE) : null); Uri url = tryCreateUrl(urlString); String packageName = (String) (packages.size() > i ? packages.get(i).get(KEY_AL_VALUE) : null); String className = (String) (classes.size() > i ? classes.get(i).get(KEY_AL_VALUE) : null); String appName = (String) (appNames.size() > i ? appNames.get(i).get(KEY_AL_VALUE) : null); AppLink.Target target = new AppLink.Target(packageName, className, url, appName); targets.add(target); } } Uri webUrl = destination; List<Map<String, Object>> webMapList = (List<Map<String, Object>>) appLinkDict.get(KEY_WEB); if (webMapList != null && webMapList.size() > 0) { Map<String, Object> webMap = webMapList.get(0); List<Map<String, Object>> urls = (List<Map<String, Object>>) webMap.get(KEY_WEB_URL); List<Map<String, Object>> shouldFallbacks = (List<Map<String, Object>>) webMap.get(KEY_SHOULD_FALLBACK); if (shouldFallbacks != null && shouldFallbacks.size() > 0) { String shouldFallbackString = (String) shouldFallbacks.get(0).get(KEY_AL_VALUE); if (Arrays.asList("no", "false", "0").contains(shouldFallbackString.toLowerCase())) { webUrl = null; } } if (webUrl != null && urls != null && urls.size() > 0) { String webUrlString = (String) urls.get(0).get(KEY_AL_VALUE); webUrl = tryCreateUrl(webUrlString); } } return new AppLink(destination, targets, webUrl); } private static Uri tryCreateUrl(String urlString) { if (urlString == null) { return null; } return Uri.parse(urlString); } /** * Gets a string with the proper encoding (including using the charset specified in the MIME type * of the request) from a URLConnection. */ private static String readFromConnection(URLConnection connection) throws IOException { InputStream stream; if (connection instanceof HttpURLConnection) { HttpURLConnection httpConnection = (HttpURLConnection) connection; try { stream = connection.getInputStream(); } catch (Exception e) { stream = httpConnection.getErrorStream(); } } else { stream = connection.getInputStream(); } try { ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int read = 0; while ((read = stream.read(buffer)) != -1) { output.write(buffer, 0, read); } String charset = connection.getContentEncoding(); if (charset == null) { String mimeType = connection.getContentType(); String[] parts = mimeType.split(";"); for (String part : parts) { part = part.trim(); if (part.startsWith("charset=")) { charset = part.substring("charset=".length()); break; } } if (charset == null) { charset = "UTF-8"; } } return new String(output.toByteArray(), charset); } finally { stream.close(); } } }