blob: 7f190692f2f6af73ee7d2f9aa40bddc18b16cc56 [file] [log] [blame]
/*
* Copyright (C) 2017 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_EXPORTED;
import static com.android.SdkConstants.ATTR_HOST;
import static com.android.SdkConstants.ATTR_MIME_TYPE;
import static com.android.SdkConstants.ATTR_PATH;
import static com.android.SdkConstants.ATTR_PATH_PATTERN;
import static com.android.SdkConstants.ATTR_PATH_PREFIX;
import static com.android.SdkConstants.ATTR_PORT;
import static com.android.SdkConstants.ATTR_SCHEME;
import static com.android.SdkConstants.PREFIX_RESOURCE_REF;
import static com.android.SdkConstants.PREFIX_THEME_REF;
import static com.android.SdkConstants.TAG_ACTIVITY;
import static com.android.SdkConstants.TAG_ACTIVITY_ALIAS;
import static com.android.SdkConstants.TAG_DATA;
import static com.android.SdkConstants.TAG_INTENT_FILTER;
import static com.android.SdkConstants.TOOLS_URI;
import static com.android.SdkConstants.VALUE_TRUE;
import static com.android.tools.lint.checks.AndroidPatternMatcher.PATTERN_LITERAL;
import static com.android.tools.lint.checks.AndroidPatternMatcher.PATTERN_PREFIX;
import static com.android.tools.lint.checks.AndroidPatternMatcher.PATTERN_SIMPLE_GLOB;
import static com.android.tools.lint.detector.api.Lint.isDataBindingExpression;
import static com.android.tools.lint.detector.api.Lint.isManifestPlaceHolderExpression;
import static com.android.tools.lint.detector.api.Lint.resolvePlaceHolders;
import static com.android.utils.XmlUtils.getFirstSubTagByName;
import static com.android.utils.XmlUtils.getNextTagByName;
import static com.android.utils.XmlUtils.getPreviousTagByName;
import static com.android.utils.XmlUtils.getSubTagsByName;
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 com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.rendering.api.ResourceNamespace;
import com.android.ide.common.rendering.api.ResourceValue;
import com.android.ide.common.resources.ResourceItem;
import com.android.ide.common.resources.ResourceRepository;
import com.android.resources.ResourceType;
import com.android.resources.ResourceUrl;
import com.android.tools.lint.client.api.LintClient;
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.LintFix;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Project;
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.utils.CharSequences;
import com.google.common.base.Joiner;
import com.google.common.collect.Lists;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.regex.Pattern;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
/** Checks for invalid app links URLs */
public class AppLinksValidDetector extends Detector implements XmlScanner {
private static final Implementation IMPLEMENTATION =
new Implementation(AppLinksValidDetector.class, Scope.MANIFEST_SCOPE);
/** The main issue discovered by this detector */
public static final Issue TEST_URL =
Issue.create(
"TestAppLink",
"Unmatched URLs",
"Using one or more `tools:validation testUrl=\"some url\"/>` elements "
+ "in your manifest allows the link attributes in your intent filter to be "
+ "checked for matches.",
Category.CORRECTNESS,
5,
Severity.FATAL,
IMPLEMENTATION);
public static final Issue VALIDATION =
Issue.create(
"AppLinkUrlError",
"URL not supported by app for Firebase App Indexing",
"Ensure the URL is supported by your app, to get installs and traffic to "
+ "your app from Google Search.",
Category.USABILITY,
5,
Severity.ERROR,
IMPLEMENTATION)
.addMoreInfo("https://g.co/AppIndexing/AndroidStudio");
/**
* Only used for compatibility issue lookup (the driver suppression check takes an issue, not an
* id)
*/
private static final Issue _OLD_ISSUE_URL =
Issue.create(
"GoogleAppIndexingUrlError",
"?",
"?",
Category.USABILITY,
5,
Severity.ERROR,
IMPLEMENTATION);
private static final String TAG_VALIDATION = "validation";
/** Constructs a new {@link AppLinksValidDetector} check */
public AppLinksValidDetector() {}
// ---- Implements XmlScanner ----
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(TAG_ACTIVITY, TAG_ACTIVITY_ALIAS);
}
private static void reportUrlError(
@NonNull XmlContext context,
@NonNull Node node,
@NonNull Location location,
@NonNull String message) {
reportUrlError(context, node, location, message, null);
}
private static void reportUrlError(
@NonNull XmlContext context,
@NonNull Node node,
@NonNull Location location,
@NonNull String message,
@Nullable LintFix quickfixData) {
// Validation errors were reported here before
if (context.getDriver().isSuppressed(context, _OLD_ISSUE_URL, node)) {
return;
}
context.report(VALIDATION, node, location, message, quickfixData);
}
private static void reportTestUrlFailure(
@NonNull XmlContext context,
@NonNull Node node,
@NonNull Location location,
@NonNull String message) {
context.report(TEST_URL, node, location, message);
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element activity) {
List<UriInfo> infos = createUriInfos(activity, context);
Element current = getFirstSubTagByName(activity, TAG_VALIDATION);
while (current != null) {
if (TOOLS_URI.equals(current.getNamespaceURI())) {
Attr testUrlAttr = current.getAttributeNode("testUrl");
if (testUrlAttr == null) {
String message = "Expected `testUrl` attribute";
reportUrlError(context, current, context.getLocation(current), message);
} else {
String testUrlString = testUrlAttr.getValue();
try {
URL testUrl = new URL(testUrlString);
String reason = testElement(testUrl, infos);
if (reason != null) {
reportTestUrlFailure(
context,
testUrlAttr,
context.getValueLocation(testUrlAttr),
reason);
}
} catch (MalformedURLException e) {
String message = "Invalid test URL: " + e.getLocalizedMessage();
reportTestUrlFailure(
context,
testUrlAttr,
context.getValueLocation(testUrlAttr),
message);
}
}
} else {
reportTestUrlFailure(
context,
current,
context.getNameLocation(current),
"Validation nodes should be in the `tools:` namespace to "
+ "ensure they are removed from the manifest at build time");
}
current = getNextTagByName(current, TAG_VALIDATION);
}
}
/**
* Given an activity (or activity alias) element, looks up the intent filters that contain URIs
* and creates a list of {@link UriInfo} objects for these
*
* @param activity the activity
* @param context an <b>optional</b> lint context to pass in; if provided, lint will validate
* the activity attributes and report link-related problems (such as unknown scheme, wrong
* port number etc)
* @return a list of URI infos, if any
*/
@NonNull
public static List<UriInfo> createUriInfos(
@NonNull Element activity, @Nullable XmlContext context) {
Element intent = getFirstSubTagByName(activity, TAG_INTENT_FILTER);
List<AppLinksValidDetector.UriInfo> infos = Lists.newArrayList();
while (intent != null) {
UriInfo uriInfo = checkIntent(context, intent, activity);
if (uriInfo != null) {
infos.add(uriInfo);
}
intent = getNextTagByName(intent, TAG_INTENT_FILTER);
}
return infos;
}
/**
* Given a test URL and a list of URI infos (previously returned from {@link
* #createUriInfos(Element, XmlContext)}) this method checks whether the URL matches, and if so
* returns null, otherwise returning the reason for the mismatch.
*
* @param testUrl the URL to test
* @param infos the URL information
* @return null for a match, otherwise the failure reason
*/
@Nullable
public static String testElement(@NonNull URL testUrl, @NonNull List<UriInfo> infos) {
List<String> reasons = null;
for (UriInfo info : infos) {
String reason = info.match(testUrl);
if (reason == null) {
return null; // found a match
}
if (reasons == null) {
reasons = Lists.newArrayList();
}
if (!reasons.contains(reason)) {
reasons.add(reason);
}
}
if (reasons != null) {
return "Test URL " + Joiner.on(" or ").join(reasons);
} else {
return null;
}
}
/** URL information from an intent filter */
public static class UriInfo {
private final List<String> schemes;
private final List<String> hosts;
private final List<String> ports;
private final List<AndroidPatternMatcher> paths;
public UriInfo(
List<String> schemes,
List<String> hosts,
List<String> ports,
List<AndroidPatternMatcher> paths) {
this.schemes = schemes;
this.hosts = hosts;
this.ports = ports;
this.paths = paths;
}
/**
* Matches a URL against this info, and returns null if successful or the failure reason if
* not a match
*
* @param testUrl the URL to match
* @return null for a successful match or the failure reason
*/
@Nullable
public String match(@NonNull URL testUrl) {
// Check schemes
if (schemes != null) {
boolean schemeOk =
schemes.stream()
.anyMatch(
scheme ->
scheme.equals(testUrl.getProtocol())
|| isSubstituted(scheme));
if (!schemeOk) {
return String.format(
"did not match scheme %1$s", Joiner.on(", ").join(schemes));
}
}
if (hosts != null) {
boolean hostOk =
hosts.stream()
.anyMatch(
host ->
matchesHost(testUrl.getHost(), host)
|| isSubstituted(host));
if (!hostOk) {
return String.format("did not match host %1$s", Joiner.on(", ").join(hosts));
}
}
// Port matching:
boolean portOk = false;
if (testUrl.getPort() != -1) {
String testPort = Integer.toString(testUrl.getPort());
if (ports != null) {
portOk =
ports.stream()
.anyMatch(port -> testPort.equals(port) || isSubstituted(port));
}
} else if (ports == null) {
portOk = true;
}
if (!portOk) {
String portList = ports == null ? "none" : Joiner.on(", ").join(ports);
return String.format("did not match port %1$s", portList);
}
if (paths != null) {
String testPath = testUrl.getPath();
boolean pathOk =
paths.stream()
.anyMatch(
matcher ->
isSubstituted(matcher.getPath())
|| matcher.match(testPath));
if (!pathOk) {
StringBuilder sb = new StringBuilder();
paths.forEach(
matcher -> sb.append("path ").append(matcher.toString()).append(", "));
if (CharSequences.endsWith(sb, ", ", true)) {
sb.setLength(sb.length() - 2);
}
String message = String.format("did not match %1$s", sb.toString());
if (containsUpperCase(paths) || CharSequences.containsUpperCase(testPath)) {
message += " Note that matching is case sensitive.";
}
return message;
}
}
return null; // OK
}
}
@Nullable
private static UriInfo checkIntent(
@Nullable XmlContext context, @NonNull Element intent, @NonNull Element activity) {
Element firstData = getFirstSubTagByName(intent, TAG_DATA);
boolean actionView = hasActionView(intent);
boolean browsable = isBrowsable(intent);
if (actionView && context != null) {
ensureExported(context, activity, intent);
}
if (firstData == null) {
if (actionView && browsable && context != null) {
// If this activity is an ACTION_VIEW action with category BROWSABLE, but doesn't
// have data node, it's a likely mistake
reportUrlError(
context, intent, context.getLocation(intent), "Missing data element");
}
return null;
}
List<String> schemes = null;
List<String> hosts = null;
List<String> ports = null;
List<AndroidPatternMatcher> paths = null;
boolean hasMimeType = false;
for (Element data = firstData; data != null; data = getNextTagByName(data, TAG_DATA)) {
Attr mimeType = data.getAttributeNodeNS(ANDROID_URI, ATTR_MIME_TYPE);
if (mimeType != null) {
hasMimeType = true;
if (context != null) {
String mimeTypeValue = mimeType.getValue();
String resolved =
resolvePlaceHolders(context.getProject(), mimeTypeValue, null);
if (CharSequences.containsUpperCase(resolved)) {
String message =
"Mime-type matching is case sensitive and should only "
+ "use lower-case characters";
if (!CharSequences.containsUpperCase(
resolvePlaceHolders(null, mimeTypeValue, null))) {
// The upper case character is only present in the substituted
// manifest place holders, so include them in the message
message += " (without placeholders, value is `" + resolved + "`)";
}
reportUrlError(
context, mimeType, context.getValueLocation(mimeType), message);
}
}
}
schemes = addAttribute(context, ATTR_SCHEME, schemes, data);
hosts = addAttribute(context, ATTR_HOST, hosts, data);
ports = addAttribute(context, ATTR_PORT, ports, data);
paths = addMatcher(context, ATTR_PATH, PATTERN_LITERAL, paths, data);
paths = addMatcher(context, ATTR_PATH_PREFIX, PATTERN_PREFIX, paths, data);
paths = addMatcher(context, ATTR_PATH_PATTERN, PATTERN_SIMPLE_GLOB, paths, data);
// Platform also has pathAdvancedPattern using type PATTERN_ADVANCED_GLOB but it
// doesn't seem exposed to developers
}
if (actionView && browsable && schemes == null && !hasMimeType && context != null) {
// If this activity is an action view, is browsable, but has neither a
// URL nor mimeType, it may be a mistake and we will report error.
reportUrlError(
context,
firstData,
context.getLocation(firstData),
"Missing URL for the intent filter");
}
boolean isHttp = false;
boolean implicitSchemes = false;
if (schemes == null) {
if (hasMimeType) {
// Per documentation
// https://developer.android.com/guide/topics/manifest/data-element.html
// "If the filter has a data type set (the mimeType attribute) but no scheme, the
// content: and file: schemes are assumed."
schemes = Lists.newArrayList();
schemes.add("content");
schemes.add("file");
implicitSchemes = true;
}
} else {
for (String scheme : schemes) {
if ("http".equals(scheme) || "https".equals(scheme)) {
isHttp = true;
break;
}
}
}
// Validation
if (context != null) {
// At least one scheme must be specified
boolean hasScheme = schemes != null;
if (!hasScheme && (hosts != null || paths != null || ports != null)) {
LintFix fix = LintFix.create().set(ANDROID_URI, ATTR_SCHEME, "http").build();
reportUrlError(
context,
firstData,
context.getLocation(firstData),
"At least one `scheme` must be specified",
fix);
}
if (hosts == null && (paths != null || ports != null)) {
LintFix fix = LintFix.create().set().todo(ANDROID_URI, ATTR_HOST).build();
reportUrlError(
context,
firstData,
context.getLocation(firstData),
"At least one `host` must be specified",
fix);
}
// If this activity is an ACTION_VIEW action, has a http URL but doesn't have
// BROWSABLE, it may be a mistake and and we will report warning.
if (actionView && isHttp && !browsable) {
reportUrlError(
context,
intent,
context.getLocation(intent),
"Activity supporting ACTION_VIEW is not set as BROWSABLE");
}
if (actionView && (!hasScheme || implicitSchemes)) {
LintFix fix = LintFix.create().set(ANDROID_URI, ATTR_SCHEME, "http").build();
reportUrlError(context, intent, context.getLocation(intent), "Missing URL", fix);
}
}
return new UriInfo(schemes, hosts, ports, paths);
}
private static void ensureExported(
@NonNull XmlContext context, @NonNull Element activity, @NonNull Element intent) {
Attr exported = activity.getAttributeNodeNS(ANDROID_URI, ATTR_EXPORTED);
if (exported == null) {
return;
}
if (VALUE_TRUE.equals(exported.getValue())) {
return;
}
// Make sure there isn't some *earlier* intent filter for this activity
// that also reported this; we don't want duplicate warnings
Element prevIntent = getPreviousTagByName(intent, TAG_INTENT_FILTER);
while (prevIntent != null) {
if (hasActionView(prevIntent)) {
return;
}
prevIntent = getNextTagByName(prevIntent, TAG_INTENT_FILTER);
}
// Report error if the activity supporting action view is not exported.
reportUrlError(
context,
activity,
context.getLocation(activity),
"Activity supporting ACTION_VIEW is not exported");
}
/**
* Check if the intent filter supports action view.
*
* @param intent the intent filter
* @return true if it does
*/
static boolean hasActionView(@NonNull Element intent) {
for (Element action : getSubTagsByName(intent, NODE_ACTION)) {
if (action.hasAttributeNS(ANDROID_URI, ATTRIBUTE_NAME)) {
Attr attr = action.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_NAME);
if (attr.getValue().equals("android.intent.action.VIEW")) {
return true;
}
}
}
return false;
}
/**
* Check if the intent filter is browsable.
*
* @param intent the intent filter
* @return true if it does
*/
private static boolean isBrowsable(@NonNull Element intent) {
for (Element e : getSubTagsByName(intent, NODE_CATEGORY)) {
if (e.hasAttributeNS(ANDROID_URI, ATTRIBUTE_NAME)) {
Attr attr = e.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_NAME);
if (attr.getNodeValue().equals("android.intent.category.BROWSABLE")) {
return true;
}
}
}
return false;
}
private static boolean containsUpperCase(@Nullable List<AndroidPatternMatcher> matchers) {
return matchers != null
&& matchers.stream().anyMatch(m -> CharSequences.containsUpperCase(m.getPath()));
}
@Nullable
private static List<String> addAttribute(
@Nullable XmlContext context,
@NonNull String attributeName,
@Nullable List<String> current,
Element data) {
Attr attribute = data.getAttributeNodeNS(ANDROID_URI, attributeName);
if (attribute != null) {
String value = attribute.getValue();
if (requireNonEmpty(context, attribute, value)) {
return current;
}
if (value.startsWith(PREFIX_RESOURCE_REF) || value.startsWith(PREFIX_THEME_REF)) {
value = replaceUrlWithValue(context, value);
}
if (current == null) {
current = Lists.newArrayListWithCapacity(4);
}
current.add(value);
if (isSubstituted(value)
|| value.startsWith(PREFIX_RESOURCE_REF)
|| value.startsWith(PREFIX_THEME_REF)) { // already checked but can be nested
return current;
}
// Validation
if (context != null) {
validateAttribute(context, attributeName, data, attribute, value);
}
}
return current;
}
private static void validateAttribute(
@NonNull XmlContext context,
@NonNull String attributeName,
Element data,
Attr attribute,
String value) {
// See https://developer.android.com/guide/topics/manifest/data-element.html
switch (attributeName) {
case ATTR_SCHEME:
{
if (value.endsWith(":")) {
reportUrlError(
context,
attribute,
context.getValueLocation(attribute),
"Don't include trailing colon in the `scheme` declaration");
} else if (CharSequences.containsUpperCase(value)) {
reportUrlError(
context,
attribute,
context.getValueLocation(attribute),
"Scheme matching is case sensitive and should only "
+ "use lower-case characters");
}
break;
}
case ATTR_HOST:
{
if (value.lastIndexOf('*') > 0) {
reportUrlError(
context,
attribute,
context.getValueLocation(attribute),
"The host wildcard (`*`) can only be the first character");
} else if (CharSequences.containsUpperCase(value)) {
reportUrlError(
context,
attribute,
context.getValueLocation(attribute),
"Host matching is case sensitive and should only "
+ "use lower-case characters");
}
break;
}
case ATTR_PORT:
{
try {
int port = Integer.parseInt(value); // might also throw number exc
if (port < 1 || port > 65535) {
throw new NumberFormatException();
}
} catch (NumberFormatException e) {
reportUrlError(
context,
attribute,
context.getValueLocation(attribute),
"not a valid port number");
}
// The port *only* takes effect if it's specified on the *same* XML
// element as the host (this isn't true for the other attributes,
// which can be spread out across separate <data> elements)
if (!data.hasAttributeNS(ANDROID_URI, ATTR_HOST)) {
reportUrlError(
context,
attribute,
context.getValueLocation(attribute),
"The port must be specified in the same `<data>` "
+ "element as the `host`");
}
break;
}
}
}
@Nullable
private static List<AndroidPatternMatcher> addMatcher(
@Nullable XmlContext context,
@NonNull String attributeName,
int type,
@Nullable List<AndroidPatternMatcher> current,
Element data) {
Attr attribute = data.getAttributeNodeNS(ANDROID_URI, attributeName);
if (attribute != null) {
String value = attribute.getValue();
if (requireNonEmpty(context, attribute, value)) {
return current;
}
if (current == null) {
current = Lists.newArrayListWithCapacity(4);
}
if (value.startsWith(PREFIX_RESOURCE_REF) || value.startsWith(PREFIX_THEME_REF)) {
value = replaceUrlWithValue(context, value);
}
AndroidPatternMatcher matcher = new AndroidPatternMatcher(value, type);
current.add(matcher);
if (context != null
&& !value.startsWith("/")
&& !isSubstituted(value)
&& !value.startsWith(SdkConstants.PREFIX_RESOURCE_REF)
// Only enforce / for path and prefix; for pattern it seems to
// work without
&& !attributeName.equals(ATTR_PATH_PATTERN)) {
LintFix fix =
LintFix.create()
.replace()
.text(attribute.getValue())
.with("/" + value)
.build();
reportUrlError(
context,
attribute,
context.getValueLocation(attribute),
String.format(
"`%1$s` attribute should start with `/`, but it is `" + value + "`",
attribute.getName()),
fix);
}
}
return current;
}
private static boolean requireNonEmpty(
@Nullable XmlContext context, @NonNull Attr attribute, @Nullable String value) {
if (context != null && (value == null || value.isEmpty())) {
reportUrlError(
context,
attribute,
context.getLocation(attribute),
String.format("`%1$s` cannot be empty", attribute.getName()));
return true;
}
return false;
}
@NonNull
private static String replaceUrlWithValue(@Nullable XmlContext context, @NonNull String str) {
if (context == null) {
return str;
}
LintClient client = context.getClient();
if (!client.supportsProjectResources()) {
return str;
}
ResourceUrl url = ResourceUrl.parse(str);
if (url == null || url.isFramework()) {
return str;
}
Project project = context.getProject();
ResourceRepository resources = client.getResourceRepository(project, true, true);
if (resources == null) {
return str;
}
List<ResourceItem> items =
resources.getResources(ResourceNamespace.TODO(), ResourceType.STRING, url.name);
if (items.isEmpty()) {
return str;
}
ResourceValue resourceValue = items.get(0).getResourceValue();
if (resourceValue == null) {
return str;
}
return resourceValue.getValue() == null ? str : resourceValue.getValue();
}
/**
* Check whether a given host matches the hostRegex. The hostRegex could be a regular host name,
* or it could contain only one '*', such as *.example.com, where '*' matches any string whose
* length is at least 1.
*
* @param actualHost The actual host we want to check.
* @param hostPattern The criteria host, which could contain a '*'.
* @return Whether the actualHost matches the hostRegex
*/
private static boolean matchesHost(@NonNull String actualHost, @NonNull String hostPattern) {
// Per https://developer.android.com/guide/topics/manifest/data-element.html
// the asterisk must be the first character
if (!hostPattern.startsWith("*")) {
return actualHost.equals(hostPattern);
}
try {
String pattern = ".*" + Pattern.quote(hostPattern.substring(1));
return actualHost.matches(pattern);
} catch (Throwable ignore) {
// Make sure we don't fail to compile the regex, though with the quote call
// above this really shouldn't happen
return false;
}
}
private static boolean isSubstituted(@NonNull String expression) {
return isDataBindingExpression(expression) || isManifestPlaceHolderExpression(expression);
}
}