blob: 33e49e66acb1554557a15e5ee4d68a9f946b3d33 [file] [log] [blame]
/*
* Copyright (C) 2014 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.FD_BUILD_TOOLS;
import static com.android.SdkConstants.GRADLE_PLUGIN_MINIMUM_VERSION;
import static com.android.SdkConstants.GRADLE_PLUGIN_RECOMMENDED_VERSION;
import static com.android.ide.common.repository.GradleCoordinate.COMPARE_PLUS_HIGHER;
import static com.android.tools.lint.checks.ManifestDetector.TARGET_NEWER;
import static com.android.tools.lint.detector.api.LintUtils.findSubstring;
import static com.google.common.base.Charsets.UTF_8;
import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.builder.model.AndroidLibrary;
import com.android.builder.model.Dependencies;
import com.android.builder.model.MavenCoordinates;
import com.android.builder.model.Variant;
import com.android.ide.common.repository.GradleCoordinate;
import com.android.ide.common.repository.GradleCoordinate.RevisionComponent;
import com.android.ide.common.repository.SdkMavenRepository;
import com.android.sdklib.repository.PreciseRevision;
import com.android.tools.lint.client.api.LintClient;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
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.LintUtils;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.Speed;
import com.android.tools.lint.detector.api.TextFormat;
import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Checks Gradle files for potential errors
*/
public class GradleDetector extends Detector implements Detector.GradleScanner {
private static final Implementation IMPLEMENTATION = new Implementation(
GradleDetector.class,
Scope.GRADLE_SCOPE);
/** Obsolete dependencies */
public static final Issue DEPENDENCY = Issue.create(
"GradleDependency", //$NON-NLS-1$
"Obsolete Gradle Dependency",
"This detector looks for usages of libraries where the version you are using " +
"is not the current stable release. Using older versions is fine, and there are " +
"cases where you deliberately want to stick with an older version. However, " +
"you may simply not be aware that a more recent version is available, and that is " +
"what this lint check helps find.",
Category.CORRECTNESS,
4,
Severity.WARNING,
IMPLEMENTATION);
/** Deprecated Gradle constructs */
public static final Issue DEPRECATED = Issue.create(
"GradleDeprecated", //$NON-NLS-1$
"Deprecated Gradle Construct",
"This detector looks for deprecated Gradle constructs which currently work but " +
"will likely stop working in a future update.",
Category.CORRECTNESS,
6,
Severity.WARNING,
IMPLEMENTATION);
/** Incompatible Android Gradle plugin */
public static final Issue GRADLE_PLUGIN_COMPATIBILITY = Issue.create(
"AndroidGradlePluginVersion", //$NON-NLS-1$
"Incompatible Android Gradle Plugin",
"Not all versions of the Android Gradle plugin are compatible with all versions " +
"of the SDK. If you update your tools, or if you are trying to open a project that " +
"was built with an old version of the tools, you may need to update your plugin " +
"version number.",
Category.CORRECTNESS,
8,
Severity.ERROR,
IMPLEMENTATION);
/** Invalid or dangerous paths */
public static final Issue PATH = Issue.create(
"GradlePath", //$NON-NLS-1$
"Gradle Path Issues",
"Gradle build scripts are meant to be cross platform, so file paths use " +
"Unix-style path separators (a forward slash) rather than Windows path separators " +
"(a backslash). Similarly, to keep projects portable and repeatable, avoid " +
"using absolute paths on the system; keep files within the project instead. To " +
"share code between projects, consider creating an android-library and an AAR " +
"dependency",
Category.CORRECTNESS,
4,
Severity.WARNING,
IMPLEMENTATION);
/** Constructs the IDE support struggles with */
public static final Issue IDE_SUPPORT = Issue.create(
"GradleIdeError", //$NON-NLS-1$
"Gradle IDE Support Issues",
"Gradle is highly flexible, and there are things you can do in Gradle files which " +
"can make it hard or impossible for IDEs to properly handle the project. This lint " +
"check looks for constructs that potentially break IDE support.",
Category.CORRECTNESS,
4,
Severity.ERROR,
IMPLEMENTATION);
/** Using + in versions */
public static final Issue PLUS = Issue.create(
"GradleDynamicVersion", //$NON-NLS-1$
"Gradle Dynamic Version",
"Using `+` in dependencies lets you automatically pick up the latest available " +
"version rather than a specific, named version. However, this is not recommended; " +
"your builds are not repeatable; you may have tested with a slightly different " +
"version than what the build server used. (Using a dynamic version as the major " +
"version number is more problematic than using it in the minor version position.)",
Category.CORRECTNESS,
4,
Severity.WARNING,
IMPLEMENTATION);
/** Accidentally calling a getter instead of your own methods */
public static final Issue GRADLE_GETTER = Issue.create(
"GradleGetter", //$NON-NLS-1$
"Gradle Implicit Getter Call",
"Gradle will let you replace specific constants in your build scripts with method " +
"calls, so you can for example dynamically compute a version string based on your " +
"current version control revision number, rather than hardcoding a number.\n" +
"\n" +
"When computing a version name, it's tempting to for example call the method to do " +
"that `getVersionName`. However, when you put that method call inside the " +
"`defaultConfig` block, you will actually be calling the Groovy getter for the " +
"`versionName` property instead. Therefore, you need to name your method something " +
"which does not conflict with the existing implicit getters. Consider using " +
"`compute` as a prefix instead of `get`.",
Category.CORRECTNESS,
6,
Severity.ERROR,
IMPLEMENTATION);
/** Using incompatible versions */
public static final Issue COMPATIBILITY = Issue.create(
"GradleCompatible", //$NON-NLS-1$
"Incompatible Gradle Versions",
"There are some combinations of libraries, or tools and libraries, that are " +
"incompatible, or can lead to bugs. One such incompatibility is compiling with " +
"a version of the Android support libraries that is not the latest version (or in " +
"particular, a version lower than your `targetSdkVersion`.)",
Category.CORRECTNESS,
8,
Severity.ERROR,
IMPLEMENTATION);
/** Using a string where an integer is expected */
public static final Issue STRING_INTEGER = Issue.create(
"StringShouldBeInt", //$NON-NLS-1$
"String should be int",
"The properties `compileSdkVersion`, `minSdkVersion` and `targetSdkVersion` are " +
"usually numbers, but can be strings when you are using an add-on (in the case " +
"of `compileSdkVersion`) or a preview platform (for the other two properties).\n" +
"\n" +
"However, you can not use a number as a string (e.g. \"19\" instead of 19); that " +
"will result in a platform not found error message at build/sync time.",
Category.CORRECTNESS,
8,
Severity.ERROR,
IMPLEMENTATION);
/** A newer version is available on a remote server */
public static final Issue REMOTE_VERSION = Issue.create(
"NewerVersionAvailable", //$NON-NLS-1$
"Newer Library Versions Available",
"This detector checks with a central repository to see if there are newer versions " +
"available for the dependencies used by this project. " +
"This is similar to the `GradleDependency` check, which checks for newer versions " +
"available in the Android SDK tools and libraries, but this works with any " +
"MavenCentral dependency, and connects to the library every time, which makes " +
"it more flexible but also *much* slower.",
Category.CORRECTNESS,
4,
Severity.WARNING,
IMPLEMENTATION).setEnabledByDefault(false);
/** Accidentally using octal numbers */
public static final Issue ACCIDENTAL_OCTAL = Issue.create(
"AccidentalOctal", //$NON-NLS-1$
"Accidental Octal",
"In Groovy, an integer literal that starts with a leading 0 will be interpreted " +
"as an octal number. That is usually (always?) an accident and can lead to " +
"subtle bugs, for example when used in the `versionCode` of an app.",
Category.CORRECTNESS,
2,
Severity.ERROR,
IMPLEMENTATION);
/** The Gradle plugin ID for Android applications */
public static final String APP_PLUGIN_ID = "com.android.application";
/** The Gradle plugin ID for Android libraries */
public static final String LIB_PLUGIN_ID = "com.android.library";
/** Previous plugin id for applications */
public static final String OLD_APP_PLUGIN_ID = "android";
/** Previous plugin id for libraries */
public static final String OLD_LIB_PLUGIN_ID = "android-library";
private int mMinSdkVersion;
private int mCompileSdkVersion;
private int mTargetSdkVersion;
@Override
public boolean appliesTo(@NonNull Context context, @NonNull File file) {
return true;
}
@Override
@NonNull
public Speed getSpeed(@SuppressWarnings("UnusedParameters") @NonNull Issue issue) {
return issue == REMOTE_VERSION ? Speed.REALLY_SLOW : Speed.NORMAL;
}
// ---- Implements Detector.GradleScanner ----
@Override
public void visitBuildScript(@NonNull Context context, Map<String, Object> sharedData) {
}
@SuppressWarnings("UnusedDeclaration")
protected static boolean isInterestingBlock(
@NonNull String parent,
@Nullable String parentParent) {
return parent.equals("defaultConfig")
|| parent.equals("android")
|| parent.equals("dependencies")
|| parent.equals("repositories")
|| parentParent != null && parentParent.equals("buildTypes");
}
protected static boolean isInterestingStatement(
@NonNull String statement,
@Nullable String parent) {
return parent == null && statement.equals("apply");
}
@SuppressWarnings("UnusedDeclaration")
protected static boolean isInterestingProperty(
@NonNull String property,
@SuppressWarnings("UnusedParameters")
@NonNull String parent,
@Nullable String parentParent) {
return property.equals("targetSdkVersion")
|| property.equals("buildToolsVersion")
|| property.equals("versionName")
|| property.equals("versionCode")
|| property.equals("compileSdkVersion")
|| property.equals("minSdkVersion")
|| property.equals("applicationIdSuffix")
|| property.equals("packageName")
|| property.equals("packageNameSuffix")
|| parent.equals("dependencies");
}
protected void checkOctal(
@NonNull Context context,
@NonNull String value,
@NonNull Object cookie) {
if (value.length() >= 2
&& value.charAt(0) == '0'
&& (value.length() > 2 || value.charAt(1) >= '8'
&& isInteger(value))
&& context.isEnabled(ACCIDENTAL_OCTAL)) {
String message = "The leading 0 turns this number into octal which is probably "
+ "not what was intended";
try {
long numericValue = Long.decode(value);
message += " (interpreted as " + numericValue + ")";
} catch (NumberFormatException nufe) {
message += " (and it is not a valid octal number)";
}
report(context, cookie, ACCIDENTAL_OCTAL, message);
}
}
/** Called with for example "android", "defaultConfig", "minSdkVersion", "7" */
@SuppressWarnings("UnusedDeclaration")
protected void checkDslPropertyAssignment(
@NonNull Context context,
@NonNull String property,
@NonNull String value,
@NonNull String parent,
@Nullable String parentParent,
@NonNull Object valueCookie,
@NonNull Object statementCookie) {
if (parent.equals("defaultConfig")) {
if (property.equals("targetSdkVersion")) {
int version = getIntLiteralValue(value, -1);
if (version > 0 && version < context.getClient().getHighestKnownApiLevel()) {
String message =
"Not targeting the latest versions of Android; compatibility " +
"modes apply. Consider testing and updating this version. " +
"Consult the android.os.Build.VERSION_CODES javadoc for details.";
report(context, valueCookie, TARGET_NEWER, message);
}
if (version > 0) {
mTargetSdkVersion = version;
checkTargetCompatibility(context, valueCookie);
} else {
checkIntegerAsString(context, value, valueCookie);
}
} else if (property.equals("minSdkVersion")) {
int version = getIntLiteralValue(value, -1);
if (version > 0) {
mMinSdkVersion = version;
} else {
checkIntegerAsString(context, value, valueCookie);
}
}
if (value.startsWith("0")) {
checkOctal(context, value, valueCookie);
}
if (property.equals("versionName") || property.equals("versionCode") &&
!isInteger(value) || !isStringLiteral(value)) {
// Method call -- make sure it does not match one of the getters in the
// configuration!
if ((value.equals("getVersionCode") ||
value.equals("getVersionName"))) {
String message = "Bad method name: pick a unique method name which does not "
+ "conflict with the implicit getters for the defaultConfig "
+ "properties. For example, try using the prefix compute- "
+ "instead of get-.";
report(context, valueCookie, GRADLE_GETTER, message);
}
} else if (property.equals("packageName")) {
if (isModelOlderThan011(context)) {
return;
}
String message = "Deprecated: Replace 'packageName' with 'applicationId'";
report(context, getPropertyKeyCookie(valueCookie), DEPRECATED, message);
}
} else if (property.equals("compileSdkVersion") && parent.equals("android")) {
int version = getIntLiteralValue(value, -1);
if (version > 0) {
mCompileSdkVersion = version;
checkTargetCompatibility(context, valueCookie);
} else {
checkIntegerAsString(context, value, valueCookie);
}
} else if (property.equals("buildToolsVersion") && parent.equals("android")) {
String versionString = getStringLiteralValue(value);
if (versionString != null) {
PreciseRevision version = parseRevisionSilently(versionString);
if (version != null) {
PreciseRevision recommended = getLatestBuildTools(context.getClient(),
version.getMajor());
if (recommended != null && version.compareTo(recommended) < 0) {
// Keep in sync with {@link #getOldValue} and {@link #getNewValue}
String message = "Old buildToolsVersion " + version +
"; recommended version is " + recommended + " or later";
report(context, valueCookie, DEPENDENCY, message);
}
}
}
} else if (parent.equals("dependencies")) {
if (value.startsWith("files('") && value.endsWith("')")) {
String path = value.substring("files('".length(), value.length() - 2);
if (path.contains("\\\\")) {
String message = "Do not use Windows file separators in .gradle files; "
+ "use / instead";
report(context, valueCookie, PATH, message);
} else if (new File(path.replace('/', File.separatorChar)).isAbsolute()) {
String message = "Avoid using absolute paths in .gradle files";
report(context, valueCookie, PATH, message);
}
} else {
String dependency = getStringLiteralValue(value);
if (dependency == null) {
dependency = getNamedDependency(value);
}
// If the dependency is a GString (i.e. it uses Groovy variable substitution,
// with a $variable_name syntax) then don't try to parse it.
if (dependency != null) {
GradleCoordinate gc = GradleCoordinate.parseCoordinateString(dependency);
if (gc != null && dependency.contains("$")) {
gc = resolveCoordinate(context, gc);
}
if (gc != null) {
if (gc.acceptsGreaterRevisions()) {
String message = "Avoid using + in version numbers; can lead "
+ "to unpredictable and unrepeatable builds (" + dependency + ")";
report(context, valueCookie, PLUS, message);
}
if (!dependency.startsWith(SdkConstants.GRADLE_PLUGIN_NAME) ||
!checkGradlePluginDependency(context, gc, valueCookie)) {
checkDependency(context, gc, valueCookie);
}
}
}
}
} else if (property.equals("packageNameSuffix")) {
if (isModelOlderThan011(context)) {
return;
}
String message = "Deprecated: Replace 'packageNameSuffix' with 'applicationIdSuffix'";
report(context, getPropertyKeyCookie(valueCookie), DEPRECATED, message);
} else if (property.equals("applicationIdSuffix")) {
String suffix = getStringLiteralValue(value);
if (suffix != null && !suffix.startsWith(".")) {
String message = "Package suffix should probably start with a \".\"";
report(context, valueCookie, PATH, message);
}
}
}
@Nullable
private static GradleCoordinate resolveCoordinate(@NonNull Context context,
@NonNull GradleCoordinate gc) {
assert gc.getFullRevision().contains("$") : gc.getFullRevision();
Variant variant = context.getProject().getCurrentVariant();
if (variant != null) {
Dependencies dependencies = variant.getMainArtifact().getDependencies();
for (AndroidLibrary library : dependencies.getLibraries()) {
MavenCoordinates mc = library.getResolvedCoordinates();
if (mc != null
&& mc.getGroupId().equals(gc.getGroupId())
&& mc.getArtifactId().equals(gc.getArtifactId())) {
List<RevisionComponent> revisions =
GradleCoordinate.parseRevisionNumber(mc.getVersion());
if (!revisions.isEmpty()) {
return new GradleCoordinate(mc.getGroupId(), mc.getArtifactId(),
revisions, null);
}
break;
}
}
}
return null;
}
// Convert a long-hand dependency, like
// group: 'com.android.support', name: 'support-v4', version: '21.0.+'
// into an equivalent short-hand dependency, like
// com.android.support:support-v4:21.0.+
@VisibleForTesting
@Nullable
static String getNamedDependency(@NonNull String expression) {
//if (value.startsWith("group: 'com.android.support', name: 'support-v4', version: '21.0.+'"))
if (expression.indexOf(',') != -1 && expression.contains("version:")) {
String artifact = null;
String group = null;
String version = null;
Splitter splitter = Splitter.on(',').omitEmptyStrings().trimResults();
for (String property : splitter.split(expression)) {
int colon = property.indexOf(':');
if (colon == -1) {
return null;
}
char quote = '\'';
int valueStart = property.indexOf(quote, colon + 1);
if (valueStart == -1) {
quote = '"';
valueStart = property.indexOf(quote, colon + 1);
}
if (valueStart == -1) {
// For example, "transitive: false"
continue;
}
valueStart++;
int valueEnd = property.indexOf(quote, valueStart);
if (valueEnd == -1) {
return null;
}
String value = property.substring(valueStart, valueEnd);
if (property.startsWith("group:")) {
group = value;
} else if (property.startsWith("name:")) {
artifact = value;
} else if (property.startsWith("version:")) {
version = value;
}
}
if (artifact != null && group != null && version != null) {
return group + ':' + artifact + ':' + version;
}
}
return null;
}
private void checkIntegerAsString(Context context, String value, Object valueCookie) {
// When done developing with a preview platform you might be tempted to switch from
// compileSdkVersion 'android-G'
// to
// compileSdkVersion '19'
// but that won't work; it needs to be
// compileSdkVersion 19
String string = getStringLiteralValue(value);
if (isNumberString(string)) {
String quote = Character.toString(value.charAt(0));
String message = String.format("Use an integer rather than a string here "
+ "(replace %1$s%2$s%1$s with just %2$s)", quote, string);
report(context, valueCookie, STRING_INTEGER, message);
}
}
/**
* Given an error message produced by this lint detector for the given issue type,
* returns the old value to be replaced in the source code.
* <p>
* Intended for IDE quickfix implementations.
*
* @param issue the corresponding issue
* @param errorMessage the error message associated with the error
* @param format the format of the error message
* @return the corresponding old value, or null if not recognized
*/
@Nullable
public static String getOldValue(@NonNull Issue issue, @NonNull String errorMessage,
@NonNull TextFormat format) {
errorMessage = format.toText(errorMessage);
// Consider extracting all the error strings as constants and handling this
// using the LintUtils#getFormattedParameters() method to pull back out the information
if (issue == DEPENDENCY) {
// "A newer version of com.google.guava:guava than 11.0.2 is available: 17.0.0"
if (errorMessage.startsWith("A newer ")) {
return findSubstring(errorMessage, " than ", " ");
}
if (errorMessage.startsWith("Old buildToolsVersion ")) {
return findSubstring(errorMessage, "Old buildToolsVersion ", ";");
}
// "The targetSdkVersion (20) should not be higher than the compileSdkVersion (19)"
return findSubstring(errorMessage, "targetSdkVersion (", ")");
} else if (issue == STRING_INTEGER) {
return findSubstring(errorMessage, "replace ", " with ");
} else if (issue == DEPRECATED) {
if (errorMessage.contains(GradleDetector.APP_PLUGIN_ID) &&
errorMessage.contains(GradleDetector.OLD_APP_PLUGIN_ID)) {
return GradleDetector.OLD_APP_PLUGIN_ID;
} else if (errorMessage.contains(GradleDetector.LIB_PLUGIN_ID) &&
errorMessage.contains(GradleDetector.OLD_LIB_PLUGIN_ID)) {
return GradleDetector.OLD_LIB_PLUGIN_ID;
}
// "Deprecated: Replace 'packageNameSuffix' with 'applicationIdSuffix'"
return findSubstring(errorMessage, "Replace '", "'");
} else if (issue == PLUS) {
return findSubstring(errorMessage, "(", ")");
} else if (issue == COMPATIBILITY) {
if (errorMessage.startsWith("Version 5.2.08")) {
return "5.2.08";
}
}
return null;
}
/**
* Given an error message produced by this lint detector for the given issue type,
* returns the new value to be put into the source code.
* <p>
* Intended for IDE quickfix implementations.
*
* @param issue the corresponding issue
* @param errorMessage the error message associated with the error
* @param format the format of the error message
* @return the corresponding new value, or null if not recognized
*/
@Nullable
public static String getNewValue(@NonNull Issue issue, @NonNull String errorMessage,
@NonNull TextFormat format) {
errorMessage = format.toText(errorMessage);
if (issue == DEPENDENCY) {
// "A newer version of com.google.guava:guava than 11.0.2 is available: 17.0.0"
if (errorMessage.startsWith("A newer ")) {
return findSubstring(errorMessage, " is available: ", null);
}
if (errorMessage.startsWith("Old buildToolsVersion ")) {
return findSubstring(errorMessage, " version is ", " ");
}
// "The targetSdkVersion (20) should not be higher than the compileSdkVersion (19)"
return findSubstring(errorMessage, "compileSdkVersion (", ")");
} else if (issue == STRING_INTEGER) {
return findSubstring(errorMessage, " just ", ")");
} else if (issue == DEPRECATED) {
if (errorMessage.contains(GradleDetector.APP_PLUGIN_ID) &&
errorMessage.contains(GradleDetector.OLD_APP_PLUGIN_ID)) {
return GradleDetector.APP_PLUGIN_ID;
} else if (errorMessage.contains(GradleDetector.LIB_PLUGIN_ID) &&
errorMessage.contains(GradleDetector.OLD_LIB_PLUGIN_ID)) {
return GradleDetector.LIB_PLUGIN_ID;
}
// "Deprecated: Replace 'packageNameSuffix' with 'applicationIdSuffix'"
return findSubstring(errorMessage, " with '", "'");
} else if (issue == COMPATIBILITY) {
if (errorMessage.startsWith("Version 5.2.08")) {
return findSubstring(errorMessage, "Use version ", " ");
}
}
return null;
}
private static boolean isNumberString(@Nullable String s) {
if (s == null || s.isEmpty()) {
return false;
}
for (int i = 0, n = s.length(); i < n; i++) {
if (!Character.isDigit(s.charAt(i))) {
return false;
}
}
return true;
}
protected void checkMethodCall(
@NonNull Context context,
@NonNull String statement,
@Nullable String parent,
@NonNull Map<String, String> namedArguments,
@SuppressWarnings("UnusedParameters")
@NonNull List<String> unnamedArguments,
@NonNull Object cookie) {
String plugin = namedArguments.get("plugin");
if (statement.equals("apply") && parent == null) {
boolean isOldAppPlugin = OLD_APP_PLUGIN_ID.equals(plugin);
if (isOldAppPlugin || OLD_LIB_PLUGIN_ID.equals(plugin)) {
String replaceWith = isOldAppPlugin ? APP_PLUGIN_ID : LIB_PLUGIN_ID;
String message = String.format("'%1$s' is deprecated; use '%2$s' instead", plugin,
replaceWith);
report(context, cookie, DEPRECATED, message);
}
}
}
@Nullable
private static PreciseRevision parseRevisionSilently(String versionString) {
try {
return PreciseRevision.parseRevision(versionString);
} catch (Throwable t) {
return null;
}
}
private static boolean isModelOlderThan011(@NonNull Context context) {
return LintUtils.isModelOlderThan(context.getProject().getGradleProjectModel(), 0, 11, 0);
}
private static int sMajorBuildTools;
private static PreciseRevision sLatestBuildTools;
/** Returns the latest build tools installed for the given major version.
* We just cache this once; we don't need to be accurate in the sense that if the
* user opens the SDK manager and installs a more recent version, we capture this in
* the same IDE session.
*
* @param client the associated client
* @param major the major version of build tools to look up (e.g. typically 18, 19, ...)
* @return the corresponding highest known revision
*/
@Nullable
private static PreciseRevision getLatestBuildTools(@NonNull LintClient client, int major) {
if (major != sMajorBuildTools) {
sMajorBuildTools = major;
List<PreciseRevision> revisions = Lists.newArrayList();
if (major == 21) {
revisions.add(new PreciseRevision(21, 1, 2));
} else if (major == 20) {
revisions.add(new PreciseRevision(20));
} else if (major == 19) {
revisions.add(new PreciseRevision(19, 1));
} else if (major == 18) {
revisions.add(new PreciseRevision(18, 1, 1));
}
// The above versions can go stale.
// Check if a more recent one is installed. (The above are still useful for
// people who haven't updated with the SDK manager recently.)
File sdkHome = client.getSdkHome();
if (sdkHome != null) {
File[] dirs = new File(sdkHome, FD_BUILD_TOOLS).listFiles();
if (dirs != null) {
for (File dir : dirs) {
String name = dir.getName();
if (!dir.isDirectory() || !Character.isDigit(name.charAt(0))) {
continue;
}
PreciseRevision v = parseRevisionSilently(name);
if (v != null && v.getMajor() == major) {
revisions.add(v);
}
}
}
}
if (!revisions.isEmpty()) {
sLatestBuildTools = Collections.max(revisions);
}
}
return sLatestBuildTools;
}
private void checkTargetCompatibility(Context context, Object cookie) {
if (mCompileSdkVersion > 0 && mTargetSdkVersion > 0
&& mTargetSdkVersion > mCompileSdkVersion) {
// NOTE: Keep this in sync with {@link #getOldValue} and {@link #getNewValue}
String message = "The targetSdkVersion (" + mTargetSdkVersion
+ ") should not be higher than the compileSdkVersion ("
+ mCompileSdkVersion + ")";
report(context, cookie, DEPENDENCY, message);
}
}
@Nullable
private static String getStringLiteralValue(@NonNull String value) {
if (value.length() > 2 && (value.startsWith("'") && value.endsWith("'") ||
value.startsWith("\"") && value.endsWith("\""))) {
return value.substring(1, value.length() - 1);
}
return null;
}
private static int getIntLiteralValue(@NonNull String value, int defaultValue) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
private static boolean isInteger(String token) {
return token.matches("\\d+");
}
private static boolean isStringLiteral(String token) {
return token.startsWith("\"") && token.endsWith("\"") ||
token.startsWith("'") && token.endsWith("'");
}
private void checkDependency(
@NonNull Context context,
@NonNull GradleCoordinate dependency,
@NonNull Object cookie) {
if ("com.android.support".equals(dependency.getGroupId())) {
checkSupportLibraries(context, dependency, cookie);
if (mMinSdkVersion >= 14 && "appcompat-v7".equals(dependency.getArtifactId())
&& mCompileSdkVersion >= 1 && mCompileSdkVersion < 21) {
report(context, cookie, DEPENDENCY,
"Using the appcompat library when minSdkVersion >= 14 and "
+ "compileSdkVersion < 21 is not necessary");
}
return;
} else if ("com.google.android.gms".equals(dependency.getGroupId())
&& dependency.getArtifactId() != null) {
// 5.2.08 is not supported; special case and warn about this
if ("5.2.08".equals(dependency.getFullRevision()) && context.isEnabled(COMPATIBILITY)) {
// This specific version is actually a preview version which should
// not be used (https://code.google.com/p/android/issues/detail?id=75292)
String version = "6.1.11";
// Try to find a more recent available version, if one is available
File sdkHome = context.getClient().getSdkHome();
File repository = SdkMavenRepository.GOOGLE.getRepositoryLocation(sdkHome, true);
if (repository != null) {
GradleCoordinate max = SdkMavenRepository.getHighestInstalledVersion(
dependency.getGroupId(), dependency.getArtifactId(), repository,
null, false);
if (max != null) {
if (COMPARE_PLUS_HIGHER.compare(dependency, max) < 0) {
version = max.getFullRevision();
}
}
}
String message = String.format("Version `5.2.08` should not be used; the app "
+ "can not be published with this version. Use version `%1$s` "
+ "instead.", version);
report(context, cookie, COMPATIBILITY, message);
}
checkPlayServices(context, dependency, cookie);
return;
}
PreciseRevision version = null;
Issue issue = DEPENDENCY;
if ("com.android.tools.build".equals(dependency.getGroupId()) &&
"gradle".equals(dependency.getArtifactId())) {
try {
PreciseRevision v =
PreciseRevision.parseRevision(GRADLE_PLUGIN_RECOMMENDED_VERSION);
if (!v.isPreview()) {
version = getNewerRevision(dependency, v);
}
} catch (NumberFormatException e) {
context.log(e, null);
}
} else if ("com.google.guava".equals(dependency.getGroupId()) &&
"guava".equals(dependency.getArtifactId())) {
version = getNewerRevision(dependency, new PreciseRevision(18, 0));
} else if ("com.google.code.gson".equals(dependency.getGroupId()) &&
"gson".equals(dependency.getArtifactId())) {
version = getNewerRevision(dependency, new PreciseRevision(2, 3));
} else if ("org.apache.httpcomponents".equals(dependency.getGroupId()) &&
"httpclient".equals(dependency.getArtifactId())) {
version = getNewerRevision(dependency, new PreciseRevision(4, 3, 5));
}
// Network check for really up to date libraries? Only done in batch mode
if (context.getScope().size() > 1 && context.isEnabled(REMOTE_VERSION)) {
PreciseRevision latest = getLatestVersionFromRemoteRepo(context.getClient(), dependency,
dependency.isPreview());
if (latest != null && isOlderThan(dependency, latest.getMajor(), latest.getMinor(),
latest.getMicro())) {
version = latest;
issue = REMOTE_VERSION;
}
}
if (version != null) {
String message = getNewerVersionAvailableMessage(dependency, version);
report(context, cookie, issue, message);
}
}
private static String getNewerVersionAvailableMessage(GradleCoordinate dependency,
PreciseRevision version) {
return getNewerVersionAvailableMessage(dependency, version.toString());
}
private static String getNewerVersionAvailableMessage(GradleCoordinate dependency,
String version) {
// NOTE: Keep this in sync with {@link #getOldValue} and {@link #getNewValue}
return "A newer version of " + dependency.getGroupId() + ":" +
dependency.getArtifactId() + " than " + dependency.getFullRevision() +
" is available: " + version;
}
/** TODO: Cache these results somewhere! */
@Nullable
public static PreciseRevision getLatestVersionFromRemoteRepo(@NonNull LintClient client,
@NonNull GradleCoordinate dependency, boolean allowPreview) {
return getLatestVersionFromRemoteRepo(client, dependency, true, allowPreview);
}
@Nullable
private static PreciseRevision getLatestVersionFromRemoteRepo(@NonNull LintClient client,
@NonNull GradleCoordinate dependency, boolean firstRowOnly, boolean allowPreview) {
StringBuilder query = new StringBuilder();
String encoding = UTF_8.name();
try {
query.append("http://search.maven.org/solrsearch/select?q=g:%22");
query.append(URLEncoder.encode(dependency.getGroupId(), encoding));
query.append("%22+AND+a:%22");
query.append(URLEncoder.encode(dependency.getArtifactId(), encoding));
} catch (UnsupportedEncodingException ee) {
return null;
}
query.append("%22&core=gav");
if (firstRowOnly) {
query.append("&rows=1");
}
query.append("&wt=json");
String response = readUrlData(client, dependency, query.toString());
if (response == null) {
return null;
}
// Sample response:
// {
// "responseHeader": {
// "status": 0,
// "QTime": 0,
// "params": {
// "fl": "id,g,a,v,p,ec,timestamp,tags",
// "sort": "score desc,timestamp desc,g asc,a asc,v desc",
// "indent": "off",
// "q": "g:\"com.google.guava\" AND a:\"guava\"",
// "core": "gav",
// "wt": "json",
// "rows": "1",
// "version": "2.2"
// }
// },
// "response": {
// "numFound": 37,
// "start": 0,
// "docs": [{
// "id": "com.google.guava:guava:17.0",
// "g": "com.google.guava",
// "a": "guava",
// "v": "17.0",
// "p": "bundle",
// "timestamp": 1398199666000,
// "tags": ["spec", "libraries", "classes", "google", "code"],
// "ec": ["-javadoc.jar", "-sources.jar", ".jar", "-site.jar", ".pom"]
// }]
// }
// }
// Look for version info: This is just a cheap skim of the above JSON results
boolean foundPreview = false;
int index = response.indexOf("\"response\""); //$NON-NLS-1$
while (index != -1) {
index = response.indexOf("\"v\":", index); //$NON-NLS-1$
if (index != -1) {
index += 4;
int start = response.indexOf('"', index) + 1;
int end = response.indexOf('"', start + 1);
if (end > start && start >= 0) {
PreciseRevision revision = parseRevisionSilently(response.substring(start, end));
if (revision != null) {
foundPreview = revision.isPreview();
if (allowPreview || !foundPreview) {
return revision;
}
}
}
}
}
if (!allowPreview && foundPreview && firstRowOnly) {
// Recurse: search more than the first row this time to see if we can find a
// non-preview version
return getLatestVersionFromRemoteRepo(client, dependency, false, false);
}
return null;
}
/** Normally null; used for testing */
@Nullable
@VisibleForTesting
static Map<String,String> sMockData;
@Nullable
private static String readUrlData(
@NonNull LintClient client,
@NonNull GradleCoordinate dependency,
@NonNull String query) {
// For unit testing: avoid network as well as unexpected new versions
if (sMockData != null) {
String value = sMockData.get(query);
assert value != null : query;
return value;
}
try {
URL url = new URL(query);
URLConnection connection = client.openConnection(url);
if (connection == null) {
return null;
}
try {
InputStream is = connection.getInputStream();
if (is == null) {
return null;
}
BufferedReader reader = new BufferedReader(new InputStreamReader(is, UTF_8));
try {
StringBuilder sb = new StringBuilder(500);
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
sb.append('\n');
}
return sb.toString();
} finally {
reader.close();
}
} finally {
client.closeConnection(connection);
}
} catch (IOException ioe) {
client.log(ioe, "Could not connect to maven central to look up the " +
"latest available version for %1$s", dependency);
return null;
}
}
private boolean checkGradlePluginDependency(Context context, GradleCoordinate dependency,
Object cookie) {
GradleCoordinate latestPlugin = GradleCoordinate.parseCoordinateString(
SdkConstants.GRADLE_PLUGIN_NAME +
GRADLE_PLUGIN_MINIMUM_VERSION);
if (GradleCoordinate.COMPARE_PLUS_HIGHER.compare(dependency, latestPlugin) < 0) {
String message = "You must use a newer version of the Android Gradle plugin. The "
+ "minimum supported version is " + GRADLE_PLUGIN_MINIMUM_VERSION +
" and the recommended version is " + GRADLE_PLUGIN_RECOMMENDED_VERSION;
report(context, cookie, GRADLE_PLUGIN_COMPATIBILITY, message);
return true;
}
return false;
}
private void checkSupportLibraries(Context context, GradleCoordinate dependency,
Object cookie) {
String groupId = dependency.getGroupId();
String artifactId = dependency.getArtifactId();
assert groupId != null && artifactId != null;
// See if the support library version is lower than the targetSdkVersion
if (mTargetSdkVersion > 0 && dependency.getMajorVersion() < mTargetSdkVersion &&
dependency.getMajorVersion() != GradleCoordinate.PLUS_REV_VALUE &&
// The multidex library doesn't follow normal supportlib numbering scheme
!dependency.getArtifactId().startsWith("multidex") &&
context.isEnabled(COMPATIBILITY)) {
String message = "This support library should not use a lower version ("
+ dependency.getMajorVersion() + ") than the `targetSdkVersion` ("
+ mTargetSdkVersion + ")";
report(context, cookie, COMPATIBILITY, message);
}
// Check to make sure you have the Android support repository installed
File sdkHome = context.getClient().getSdkHome();
File repository = SdkMavenRepository.ANDROID.getRepositoryLocation(sdkHome, true);
if (repository == null) {
report(context, cookie, DEPENDENCY,
"Dependency on a support library, but the SDK installation does not "
+ "have the \"Extras > Android Support Repository\" installed. "
+ "Open the SDK manager and install it.");
} else {
checkLocalMavenVersions(context, dependency, cookie, groupId, artifactId,
repository);
}
}
private void checkPlayServices(Context context, GradleCoordinate dependency, Object cookie) {
String groupId = dependency.getGroupId();
String artifactId = dependency.getArtifactId();
assert groupId != null && artifactId != null;
File sdkHome = context.getClient().getSdkHome();
File repository = SdkMavenRepository.GOOGLE.getRepositoryLocation(sdkHome, true);
if (repository == null) {
report(context, cookie, DEPENDENCY,
"Dependency on Play Services, but the SDK installation does not "
+ "have the \"Extras > Google Repository\" installed. "
+ "Open the SDK manager and install it.");
} else {
checkLocalMavenVersions(context, dependency, cookie, groupId, artifactId,
repository);
}
}
private void checkLocalMavenVersions(Context context, GradleCoordinate dependency,
Object cookie, String groupId, String artifactId, File repository) {
GradleCoordinate max = SdkMavenRepository.getHighestInstalledVersion(groupId, artifactId,
repository, null, false);
if (max != null) {
if (COMPARE_PLUS_HIGHER.compare(dependency, max) < 0
&& context.isEnabled(DEPENDENCY)) {
String message = getNewerVersionAvailableMessage(dependency, max.getFullRevision());
report(context, cookie, DEPENDENCY, message);
}
}
}
private static PreciseRevision getNewerRevision(@NonNull GradleCoordinate dependency,
@NonNull PreciseRevision revision) {
assert dependency.getGroupId() != null;
assert dependency.getArtifactId() != null;
GradleCoordinate coordinate;
if (revision.isPreview()) {
String coordinateString = dependency.getGroupId()
+ ":" + dependency.getArtifactId()
+ ":" + revision.toString();
coordinate = GradleCoordinate.parseCoordinateString(coordinateString);
} else {
coordinate = new GradleCoordinate(dependency.getGroupId(), dependency.getArtifactId(),
revision.getMajor(), revision.getMinor(), revision.getMicro());
}
if (COMPARE_PLUS_HIGHER.compare(dependency, coordinate) < 0) {
return revision;
} else {
return null;
}
}
private static boolean isOlderThan(@NonNull GradleCoordinate dependency, int major, int minor,
int micro) {
assert dependency.getGroupId() != null;
assert dependency.getArtifactId() != null;
return COMPARE_PLUS_HIGHER.compare(dependency,
new GradleCoordinate(dependency.getGroupId(),
dependency.getArtifactId(), major, minor, micro)) < 0;
}
private void report(@NonNull Context context, @NonNull Object cookie, @NonNull Issue issue,
@NonNull String message) {
if (context.isEnabled(issue)) {
// Suppressed?
// Temporarily unconditionally checking for suppress comments in Gradle files
// since Studio insists on an AndroidLint id prefix
boolean checkComments = /*context.getClient().checkForSuppressComments()
&&*/ context.containsCommentSuppress();
if (checkComments) {
int startOffset = getStartOffset(context, cookie);
if (startOffset >= 0 && context.isSuppressedWithComment(startOffset, issue)) {
return;
}
}
context.report(issue, createLocation(context, cookie), message);
}
}
@SuppressWarnings("MethodMayBeStatic")
@NonNull
protected Object getPropertyKeyCookie(@NonNull Object cookie) {
return cookie;
}
@SuppressWarnings({"MethodMayBeStatic", "UnusedDeclaration"})
@NonNull
protected Object getPropertyPairCookie(@NonNull Object cookie) {
return cookie;
}
@SuppressWarnings("MethodMayBeStatic")
protected int getStartOffset(@NonNull Context context, @NonNull Object cookie) {
return -1;
}
@SuppressWarnings({"MethodMayBeStatic", "UnusedParameters"})
protected Location createLocation(@NonNull Context context, @NonNull Object cookie) {
return null;
}
}