blob: ab0ad8fb7781d610f71751ffb813608ce1495945 [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.
*/
package com.android.tools.lint.checks;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_HOST;
import static com.android.SdkConstants.ATTR_SCHEME;
import static com.android.SdkConstants.MANIFEST_PLACEHOLDER_PREFIX;
import static com.android.SdkConstants.MANIFEST_PLACEHOLDER_SUFFIX;
import static com.android.SdkConstants.UTF_8;
import static com.android.xml.AndroidManifest.ATTRIBUTE_NAME;
import static com.android.xml.AndroidManifest.NODE_ACTION;
import static com.android.xml.AndroidManifest.NODE_CATEGORY;
import static com.android.xml.AndroidManifest.NODE_DATA;
import static com.android.xml.AndroidManifest.NODE_INTENT;
import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Detector;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.tools.lint.detector.api.XmlScanner;
import com.android.tools.lint.model.LintModelVariant;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/** Check if the App Link which needs auto verification is correctly set. */
public class AppLinksAutoVerifyDetector extends Detector implements XmlScanner {
private static final Implementation IMPLEMENTATION =
new Implementation(AppLinksAutoVerifyDetector.class, Scope.MANIFEST_SCOPE);
public static final Issue ISSUE_ERROR =
Issue.create(
"AppLinksAutoVerifyError",
"App Links Auto Verification Failure",
"Ensures that app links are correctly set and associated with website.",
Category.CORRECTNESS,
5,
Severity.ERROR,
IMPLEMENTATION)
.addMoreInfo("https://g.co/appindexing/applinks")
.setEnabledByDefault(false);
public static final Issue ISSUE_WARNING =
Issue.create(
"AppLinksAutoVerifyWarning",
"Potential App Links Auto Verification Failure",
"Ensures that app links are correctly set and associated with website.",
Category.CORRECTNESS,
5,
Severity.WARNING,
IMPLEMENTATION)
.addMoreInfo("https://g.co/appindexing/applinks")
.setEnabledByDefault(false);
private static final String ATTRIBUTE_AUTO_VERIFY = "autoVerify";
private static final String JSON_RELATIVE_PATH = "/.well-known/assetlinks.json";
@VisibleForTesting static final int STATUS_HTTP_CONNECT_FAIL = -1;
@VisibleForTesting static final int STATUS_MALFORMED_URL = -2;
@VisibleForTesting static final int STATUS_UNKNOWN_HOST = -3;
@VisibleForTesting static final int STATUS_NOT_FOUND = -4;
@VisibleForTesting static final int STATUS_WRONG_JSON_SYNTAX = -5;
@VisibleForTesting static final int STATUS_JSON_PARSE_FAIL = -6;
@VisibleForTesting static final int STATUS_HTTP_OK = 200;
/* Maps website host url to a future task which will send HTTP request to fetch the JSON file
* and also return the status code during the fetching process. */
private final Map<String, Future<HttpResult>> mFutures = Maps.newHashMap();
/* Maps website host url to host attribute in AndroidManifest.xml. */
private final Map<String, Attr> mJsonHost = Maps.newHashMap();
@Override
public void visitDocument(@NonNull XmlContext context, @NonNull Document document) {
// This check sends http request. Only done in batch mode.
if (!context.getScope().contains(Scope.ALL_JAVA_FILES)) {
return;
}
if (document.getDocumentElement() != null) {
List<Element> intents = getTags(document.getDocumentElement(), NODE_INTENT);
if (!needAutoVerification(intents)) {
return;
}
for (Element intent : intents) {
boolean actionView =
hasNamedSubTag(intent, NODE_ACTION, "android.intent.action.VIEW");
boolean browsableCategory =
hasNamedSubTag(intent, NODE_CATEGORY, "android.intent.category.BROWSABLE");
if (!actionView || !browsableCategory) {
continue;
}
mJsonHost.putAll(getJsonUrl(context, intent));
}
}
Map<String, HttpResult> results = getJsonFileAsync();
String packageName = context.getProject().getPackage();
for (Map.Entry<String, HttpResult> result : results.entrySet()) {
if (result.getValue() == null) {
continue;
}
Attr host = mJsonHost.get(result.getKey());
if (host == null) {
continue;
}
String jsonPath = result.getKey() + JSON_RELATIVE_PATH;
switch (result.getValue().mStatus) {
case STATUS_HTTP_OK:
List<String> packageNames = getPackageNameFromJson(result.getValue().mJsonFile);
if (!packageNames.contains(packageName)) {
context.report(
ISSUE_ERROR,
host,
context.getLocation(host),
String.format(
"This host does not support app links to your app. Checks the Digital Asset Links JSON file: %s",
jsonPath));
}
break;
case STATUS_HTTP_CONNECT_FAIL:
context.report(
ISSUE_WARNING,
host,
context.getLocation(host),
String.format(
"Connection to Digital Asset Links JSON file %s fails",
jsonPath));
break;
case STATUS_MALFORMED_URL:
context.report(
ISSUE_ERROR,
host,
context.getLocation(host),
String.format(
"Malformed URL of Digital Asset Links JSON file: %s. An unknown protocol is specified",
jsonPath));
break;
case STATUS_UNKNOWN_HOST:
context.report(
ISSUE_WARNING,
host,
context.getLocation(host),
String.format(
"Unknown host: %s. Check if the host exists, and check your network connection",
result.getKey()));
break;
case STATUS_NOT_FOUND:
context.report(
ISSUE_ERROR,
host,
context.getLocation(host),
String.format(
"Digital Asset Links JSON file %s is not found on the host",
jsonPath));
break;
case STATUS_WRONG_JSON_SYNTAX:
context.report(
ISSUE_ERROR,
host,
context.getLocation(host),
String.format("%s has incorrect JSON syntax", jsonPath));
break;
case STATUS_JSON_PARSE_FAIL:
context.report(
ISSUE_ERROR,
host,
context.getLocation(host),
String.format("Parsing JSON file %s fails", jsonPath));
break;
default:
context.report(
ISSUE_WARNING,
host,
context.getLocation(host),
String.format(
"HTTP request for Digital Asset Links JSON file %1$s fails. HTTP response code: %2$s",
jsonPath, result.getValue().mStatus));
}
}
}
/**
* Gets all the tag elements with a specific tag name, within a parent tag element.
*
* @param element The parent tag element.
* @return List of tag elements found.
*/
@NonNull
private static List<Element> getTags(@NonNull Element element, @NonNull String tagName) {
List<Element> tagList = Lists.newArrayList();
if (element.getTagName().equalsIgnoreCase(tagName)) {
tagList.add(element);
} else {
NodeList children = element.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child instanceof Element) {
tagList.addAll(getTags((Element) child, tagName));
}
}
}
return tagList;
}
/**
* Checks if auto verification is needed. i.e. any intent tag element's autoVerify attribute is
* set to true.
*
* @param intents The intent tag elements.
* @return true if auto verification is needed.
*/
private static boolean needAutoVerification(@NonNull List<Element> intents) {
for (Element intent : intents) {
if (intent.getAttributeNS(ANDROID_URI, ATTRIBUTE_AUTO_VERIFY)
.equals(SdkConstants.VALUE_TRUE)) {
return true;
}
}
return false;
}
/**
* Checks if the element has a sub tag with specific name and specific name attribute.
*
* @param element The tag element.
* @param tagName The name of the sub tag.
* @param nameAttrValue The value of the name attribute.
* @return If the element has such a sub tag.
*/
private static boolean hasNamedSubTag(
@NonNull Element element, @NonNull String tagName, @NonNull String nameAttrValue) {
NodeList children = element.getElementsByTagName(tagName);
for (int i = 0; i < children.getLength(); i++) {
Element e = (Element) children.item(i);
if (e.getAttributeNS(ANDROID_URI, ATTRIBUTE_NAME).equals(nameAttrValue)) {
return true;
}
}
return false;
}
/**
* Gets the urls of all the host from which Digital Asset Links JSON files will be fetched.
*
* @param intent The intent tag element.
* @return List of JSON file urls.
*/
@NonNull
private static Map<String, Attr> getJsonUrl(
@NonNull XmlContext context, @NonNull Element intent) {
List<String> schemes = Lists.newArrayList();
List<Attr> hosts = Lists.newArrayList();
NodeList dataTags = intent.getElementsByTagName(NODE_DATA);
for (int k = 0; k < dataTags.getLength(); k++) {
Element dataTag = (Element) dataTags.item(k);
String scheme = dataTag.getAttributeNS(ANDROID_URI, ATTR_SCHEME);
if (scheme.equals("http") || scheme.equals("https")) {
schemes.add(scheme);
}
if (dataTag.hasAttributeNS(ANDROID_URI, ATTR_HOST)) {
Attr host = dataTag.getAttributeNodeNS(ANDROID_URI, ATTR_HOST);
hosts.add(host);
}
}
Map<String, Attr> urls = Maps.newHashMap();
for (String scheme : schemes) {
for (Attr host : hosts) {
String hostname = host.getValue();
if (hostname.startsWith(SdkConstants.MANIFEST_PLACEHOLDER_PREFIX)) {
hostname = resolvePlaceHolder(context, hostname);
if (hostname == null) {
continue;
}
}
urls.put(scheme + "://" + hostname, host);
}
}
return urls;
}
@Nullable
private static String resolvePlaceHolder(
@NonNull XmlContext context, @NonNull String hostname) {
assert hostname.startsWith(SdkConstants.MANIFEST_PLACEHOLDER_PREFIX);
LintModelVariant variant = context.getProject().getBuildVariant();
if (variant != null) {
Map<String, String> placeHolders = variant.getManifestPlaceholders();
String name =
hostname.substring(
MANIFEST_PLACEHOLDER_PREFIX.length(),
hostname.length() - MANIFEST_PLACEHOLDER_SUFFIX.length());
return placeHolders.get(name);
}
return null;
}
/* Normally null. Used for testing. */
@Nullable @VisibleForTesting static Map<String, HttpResult> sMockData;
/**
* Gets all the Digital Asset Links JSON file asynchronously.
*
* @return The map between the host url and the HTTP result.
*/
private Map<String, HttpResult> getJsonFileAsync() {
if (sMockData != null) {
return sMockData;
}
ExecutorService executorService = Executors.newCachedThreadPool();
for (final Map.Entry<String, Attr> url : mJsonHost.entrySet()) {
Future<HttpResult> future =
executorService.submit(() -> getJson(url.getKey() + JSON_RELATIVE_PATH));
mFutures.put(url.getKey(), future);
}
executorService.shutdown();
Map<String, HttpResult> jsons = Maps.newHashMap();
for (Map.Entry<String, Future<HttpResult>> future : mFutures.entrySet()) {
try {
jsons.put(future.getKey(), future.getValue().get());
} catch (Exception e) {
jsons.put(future.getKey(), null);
}
}
return jsons;
}
/**
* Gets the Digital Asset Links JSON file on the website host.
*
* @param url The URL of the host on which JSON file will be fetched.
*/
@NonNull
private static HttpResult getJson(@NonNull String url) {
try {
URL urlObj = new URL(url);
HttpURLConnection connection = (HttpURLConnection) urlObj.openConnection();
if (connection == null) {
return new HttpResult(STATUS_HTTP_CONNECT_FAIL, null);
}
try {
InputStream inputStream = connection.getInputStream();
if (inputStream == null) {
return new HttpResult(connection.getResponseCode(), null);
}
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(inputStream, UTF_8))) {
String line;
StringBuilder response = new StringBuilder();
while ((line = reader.readLine()) != null) {
response.append(line);
response.append('\n');
}
try {
JsonElement jsonFile = new JsonParser().parse(response.toString());
return new HttpResult(connection.getResponseCode(), jsonFile);
} catch (JsonSyntaxException e) {
return new HttpResult(STATUS_WRONG_JSON_SYNTAX, null);
} catch (RuntimeException e) {
return new HttpResult(STATUS_JSON_PARSE_FAIL, null);
}
}
} finally {
connection.disconnect();
}
} catch (MalformedURLException e) {
return new HttpResult(STATUS_MALFORMED_URL, null);
} catch (UnknownHostException e) {
return new HttpResult(STATUS_UNKNOWN_HOST, null);
} catch (FileNotFoundException e) {
return new HttpResult(STATUS_NOT_FOUND, null);
} catch (IOException e) {
return new HttpResult(STATUS_HTTP_CONNECT_FAIL, null);
}
}
/**
* Gets the package names of all the apps from the Digital Asset Links JSON file.
*
* @param element The JsonElement of the json file.
* @return All the package names.
*/
private static List<String> getPackageNameFromJson(JsonElement element) {
List<String> packageNames = Lists.newArrayList();
if (element instanceof JsonArray) {
JsonArray jsonArray = (JsonArray) element;
for (int i = 0; i < jsonArray.size(); i++) {
JsonElement app = jsonArray.get(i);
if (app instanceof JsonObject) {
JsonObject target = ((JsonObject) app).getAsJsonObject("target");
if (target != null) {
// Checks namespace to ensure it is an app statement.
JsonElement namespace = target.get("namespace");
JsonElement packageName = target.get("package_name");
if (namespace != null
&& namespace.getAsString().equals("android_app")
&& packageName != null) {
packageNames.add(packageName.getAsString());
}
}
}
}
}
return packageNames;
}
/* For storing the result of getting Digital Asset Links Json File */
@VisibleForTesting
static final class HttpResult {
/* HTTP response code or others errors related to HTTP connection, JSON file parsing. */
private final int mStatus;
private final JsonElement mJsonFile;
@VisibleForTesting
HttpResult(int status, JsonElement jsonFile) {
mStatus = status;
mJsonFile = jsonFile;
}
}
}