/* * 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; } }