Files
packages/script/tool/lib/src/gradle_check_command.dart
stuartmorgan 5cc71d00ac Update Flutter wiki links (#6789)
Updates all links to the Flutter wiki to point to their new location in the flutter/flutter repository. (The sole exception is a link to a doc that doesn't have a final home yet, and is linked from legacy code anyway so doesn't really need to be updated.)

While touching the PR template, makes a few minor improvements:
- Removes the breaking change discussion that doesn't apply to this repository, as breaking changes are handled totally differently for packages (and is covered by the link to docs about Dart versioning).
- Adds text and a link to reflect the fact that some PRs can be changelog-exempt.
2024-05-26 12:47:23 +00:00

517 lines
20 KiB
Dart

// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:file/file.dart';
import 'package:meta/meta.dart';
import 'package:pub_semver/pub_semver.dart';
import 'common/core.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/plugin_utils.dart';
import 'common/repository_package.dart';
/// The lowest `ext.kotlin_version` that example apps are allowed to use.
@visibleForTesting
final Version minKotlinVersion = Version(1, 7, 10);
/// A command to enforce gradle file conventions and best practices.
class GradleCheckCommand extends PackageLoopingCommand {
/// Creates an instance of the gradle check command.
GradleCheckCommand(super.packagesDir);
@override
final String name = 'gradle-check';
@override
List<String> get aliases => <String>['check-gradle'];
@override
final String description =
'Checks that gradle files follow repository conventions.';
@override
bool get hasLongOutput => false;
@override
PackageLoopingType get packageLoopingType =>
PackageLoopingType.includeAllSubpackages;
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
if (!package.platformDirectory(FlutterPlatform.android).existsSync()) {
return PackageResult.skip('No android/ directory.');
}
const String exampleDirName = 'example';
final bool isExample = package.directory.basename == exampleDirName ||
package.directory.parent.basename == exampleDirName;
if (!_validateBuildGradles(package, isExample: isExample)) {
return PackageResult.fail();
}
return PackageResult.success();
}
bool _validateBuildGradles(RepositoryPackage package,
{required bool isExample}) {
final Directory androidDir =
package.platformDirectory(FlutterPlatform.android);
final File topLevelGradleFile = _getBuildGradleFile(androidDir);
// This is tracked as a variable rather than a sequence of &&s so that all
// failures are reported at once, not just the first one.
bool succeeded = true;
if (isExample) {
if (!_validateExampleTopLevelBuildGradle(package, topLevelGradleFile)) {
succeeded = false;
}
final File topLevelSettingsGradleFile =
_getSettingsGradleFile(androidDir);
if (!_validateExampleTopLevelSettingsGradle(
package, topLevelSettingsGradleFile)) {
succeeded = false;
}
final File appGradleFile =
_getBuildGradleFile(androidDir.childDirectory('app'));
if (!_validateExampleAppBuildGradle(package, appGradleFile)) {
succeeded = false;
}
} else {
succeeded = _validatePluginBuildGradle(package, topLevelGradleFile);
}
return succeeded;
}
// Returns the gradle file in the given directory.
File _getBuildGradleFile(Directory dir) => dir.childFile('build.gradle');
// Returns the settings gradle file in the given directory.
File _getSettingsGradleFile(Directory dir) =>
dir.childFile('settings.gradle');
// Returns the main/AndroidManifest.xml file for the given package.
File _getMainAndroidManifest(RepositoryPackage package,
{required bool isExample}) {
final Directory androidDir =
package.platformDirectory(FlutterPlatform.android);
final Directory baseDir =
isExample ? androidDir.childDirectory('app') : androidDir;
return baseDir
.childDirectory('src')
.childDirectory('main')
.childFile('AndroidManifest.xml');
}
bool _isCommented(String line) => line.trim().startsWith('//');
/// Validates the build.gradle file for a plugin
/// (some_plugin/android/build.gradle).
bool _validatePluginBuildGradle(RepositoryPackage package, File gradleFile) {
print('${indentation}Validating '
'${getRelativePosixPath(gradleFile, from: package.directory)}.');
final String contents = gradleFile.readAsStringSync();
final List<String> lines = contents.split('\n');
// This is tracked as a variable rather than a sequence of &&s so that all
// failures are reported at once, not just the first one.
bool succeeded = true;
if (!_validateNamespace(package, contents, isExample: false)) {
succeeded = false;
}
if (!_validateCompatibilityVersions(lines)) {
succeeded = false;
}
if (!_validateGradleDrivenLintConfig(package, lines)) {
succeeded = false;
}
return succeeded;
}
/// Documentation url for Artifact hub implementation in flutter repo's.
@visibleForTesting
static const String artifactHubDocumentationString =
r'https://github.com/flutter/flutter/blob/master/docs/ecosystem/Plugins-and-Packages-repository-structure.md#gradle-structure';
/// String printed as example of valid example root build.gradle repository
/// configuration that enables artifact hub env variable.
@visibleForTesting
static const String exampleRootGradleArtifactHubString = '''
// See $artifactHubDocumentationString for more info.
def artifactRepoKey = 'ARTIFACT_HUB_REPOSITORY'
if (System.getenv().containsKey(artifactRepoKey)) {
println "Using artifact hub"
maven { url System.getenv(artifactRepoKey) }
}
''';
/// Validates that [gradleLines] reads and uses a artifiact hub repository
/// when ARTIFACT_HUB_REPOSITORY is set.
///
/// Required in root gradle file.
bool _validateArtifactHubUsage(
RepositoryPackage example, List<String> gradleLines) {
// Gradle variable name used to hold environment variable string.
const String keyVariable = 'artifactRepoKey';
final RegExp keyPresentRegex =
RegExp('$keyVariable' r"\s+=\s+'ARTIFACT_HUB_REPOSITORY'");
final RegExp documentationPresentRegex = RegExp(
r'github\.com.*flutter.*blob.*Plugins-and-Packages-repository-structure.*gradle-structure');
final RegExp keyReadRegex =
RegExp(r'if.*System\.getenv.*\.containsKey.*' '$keyVariable');
final RegExp keyUsedRegex =
RegExp(r'maven.*url.*System\.getenv\(' '$keyVariable');
final bool keyPresent =
gradleLines.any((String line) => keyPresentRegex.hasMatch(line));
final bool documentationPresent = gradleLines
.any((String line) => documentationPresentRegex.hasMatch(line));
final bool keyRead =
gradleLines.any((String line) => keyReadRegex.hasMatch(line));
final bool keyUsed =
gradleLines.any((String line) => keyUsedRegex.hasMatch(line));
if (!(documentationPresent && keyPresent && keyRead && keyUsed)) {
printError('Failed Artifact Hub validation. Include the following in '
'example root build.gradle:\n$exampleRootGradleArtifactHubString');
}
return keyPresent && documentationPresent && keyRead && keyUsed;
}
/// Validates the top-level settings.gradle for an example app (e.g.,
/// some_package/example/android/settings.gradle).
bool _validateExampleTopLevelSettingsGradle(
RepositoryPackage package, File gradleSettingsFile) {
print('${indentation}Validating '
'${getRelativePosixPath(gradleSettingsFile, from: package.directory)}.');
final String contents = gradleSettingsFile.readAsStringSync();
final List<String> lines = contents.split('\n');
// This is tracked as a variable rather than a sequence of &&s so that all
// failures are reported at once, not just the first one.
bool succeeded = true;
if (!_validateArtifactHubSettingsUsage(package, lines)) {
succeeded = false;
}
return succeeded;
}
/// String printed as example of valid example root settings.gradle repository
/// configuration that enables artifact hub env variable.
@visibleForTesting
static String exampleRootSettingsArtifactHubString = '''
// See $artifactHubDocumentationString for more info.
buildscript {
repositories {
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
classpath "gradle.plugin.com.google.cloud.artifactregistry:artifactregistry-gradle-plugin:2.2.1"
}
}
apply plugin: "com.google.cloud.artifactregistry.gradle-plugin"
''';
/// Validates that [gradleLines] reads and uses a artifiact hub repository
/// when ARTIFACT_HUB_REPOSITORY is set.
///
/// Required in root gradle file.
bool _validateArtifactHubSettingsUsage(
RepositoryPackage example, List<String> gradleLines) {
final RegExp documentationPresentRegex = RegExp(
r'github\.com.*flutter.*blob.*Plugins-and-Packages-repository-structure.*gradle-structure');
final RegExp artifactRegistryDefinitionRegex = RegExp(
r'classpath.*gradle\.plugin\.com\.google\.cloud\.artifactregistry:artifactregistry-gradle-plugin');
final RegExp artifactRegistryPluginApplyRegex = RegExp(
r'apply.*plugin.*com\.google\.cloud\.artifactregistry\.gradle-plugin');
final bool documentationPresent = gradleLines
.any((String line) => documentationPresentRegex.hasMatch(line));
final bool artifactRegistryDefined = gradleLines
.any((String line) => artifactRegistryDefinitionRegex.hasMatch(line));
final bool artifactRegistryPluginApplied = gradleLines
.any((String line) => artifactRegistryPluginApplyRegex.hasMatch(line));
if (!(documentationPresent &&
artifactRegistryDefined &&
artifactRegistryPluginApplied)) {
printError('Failed Artifact Hub validation. Include the following in '
'example root settings.gradle:\n$exampleRootSettingsArtifactHubString');
}
return documentationPresent &&
artifactRegistryDefined &&
artifactRegistryPluginApplied;
}
/// Validates the top-level build.gradle for an example app (e.g.,
/// some_package/example/android/build.gradle).
bool _validateExampleTopLevelBuildGradle(
RepositoryPackage package, File gradleFile) {
print('${indentation}Validating '
'${getRelativePosixPath(gradleFile, from: package.directory)}.');
final String contents = gradleFile.readAsStringSync();
final List<String> lines = contents.split('\n');
// This is tracked as a variable rather than a sequence of &&s so that all
// failures are reported at once, not just the first one.
bool succeeded = true;
if (!_validateJavacLintConfig(package, lines)) {
succeeded = false;
}
if (!_validateKotlinVersion(package, lines)) {
succeeded = false;
}
if (!_validateArtifactHubUsage(package, lines)) {
succeeded = false;
}
return succeeded;
}
/// Validates the app-level build.gradle for an example app (e.g.,
/// some_package/example/android/app/build.gradle).
bool _validateExampleAppBuildGradle(
RepositoryPackage package, File gradleFile) {
print('${indentation}Validating '
'${getRelativePosixPath(gradleFile, from: package.directory)}.');
final String contents = gradleFile.readAsStringSync();
// This is tracked as a variable rather than a sequence of &&s so that all
// failures are reported at once, not just the first one.
bool succeeded = true;
if (!_validateNamespace(package, contents, isExample: true)) {
succeeded = false;
}
return succeeded;
}
/// Validates that [gradleContents] sets a namespace, which is required for
/// compatibility with apps that use AGP 8+.
bool _validateNamespace(RepositoryPackage package, String gradleContents,
{required bool isExample}) {
final RegExp namespaceRegex =
RegExp('^\\s*namespace\\s+[\'"](.*?)[\'"]', multiLine: true);
final RegExpMatch? namespaceMatch =
namespaceRegex.firstMatch(gradleContents);
// For plugins, make sure the namespace is conditionalized so that it
// doesn't break client apps using AGP 4.1 and earlier (which don't have
// a namespace property, and will fail to build if it's set).
const String namespaceConditional =
'if (project.android.hasProperty("namespace"))';
String exampleSetNamespace = "namespace 'dev.flutter.foo'";
if (!isExample) {
exampleSetNamespace = '''
$namespaceConditional {
$exampleSetNamespace
}''';
}
// Wrap the namespace command in an `android` block, adding the indentation
// to make it line up correctly.
final String exampleAndroidNamespaceBlock = '''
android {
${exampleSetNamespace.split('\n').join('\n ')}
}
''';
if (namespaceMatch == null) {
final String errorMessage = '''
build.gradle must set a "namespace":
$exampleAndroidNamespaceBlock
The value must match the "package" attribute in AndroidManifest.xml, if one is
present. For more information, see:
https://developer.android.com/build/publish-library/prep-lib-release#choose-namespace
''';
printError(
'$indentation${errorMessage.split('\n').join('\n$indentation')}');
return false;
} else {
if (!isExample && !gradleContents.contains(namespaceConditional)) {
final String errorMessage = '''
build.gradle for a plugin must conditionalize "namespace":
$exampleAndroidNamespaceBlock
''';
printError(
'$indentation${errorMessage.split('\n').join('\n$indentation')}');
return false;
}
return _validateNamespaceMatchesManifest(package,
isExample: isExample, namespace: namespaceMatch.group(1)!);
}
}
/// Validates that the given namespace matches the manifest package of
/// [package] (if any; a package does not need to be in the manifest in cases
/// where compatibility with AGP <7 is no longer required).
///
/// Prints an error and returns false if validation fails.
bool _validateNamespaceMatchesManifest(RepositoryPackage package,
{required bool isExample, required String namespace}) {
final RegExp manifestPackageRegex = RegExp(r'package\s*=\s*"(.*?)"');
final String manifestContents =
_getMainAndroidManifest(package, isExample: isExample)
.readAsStringSync();
final RegExpMatch? packageMatch =
manifestPackageRegex.firstMatch(manifestContents);
if (packageMatch != null && namespace != packageMatch.group(1)) {
final String errorMessage = '''
build.gradle "namespace" must match the "package" attribute in AndroidManifest.xml, if one is present.
build.gradle namespace: "$namespace"
AndroidMastifest.xml package: "${packageMatch.group(1)}"
''';
printError(
'$indentation${errorMessage.split('\n').join('\n$indentation')}');
return false;
}
return true;
}
/// Checks for a source compatibiltiy version, so that it's explicit rather
/// than using whatever the client's local toolchaing defaults to (which can
/// lead to compile errors that show up for clients, but not in CI).
bool _validateCompatibilityVersions(List<String> gradleLines) {
final bool hasLanguageVersion = gradleLines.any((String line) =>
line.contains('languageVersion') && !_isCommented(line));
final bool hasCompabilityVersions = gradleLines.any((String line) =>
line.contains('sourceCompatibility') && !_isCommented(line)) &&
// Newer toolchains default targetCompatibility to the same value as
// sourceCompatibility, but older toolchains require it to be set
// explicitly. The exact version cutoff (and of which piece of the
// toolchain; likely AGP) is unknown; for context see
// https://github.com/flutter/flutter/issues/125482
gradleLines.any((String line) =>
line.contains('targetCompatibility') && !_isCommented(line));
if (!hasLanguageVersion && !hasCompabilityVersions) {
const String errorMessage = '''
build.gradle must set an explicit Java compatibility version.
This can be done either via "sourceCompatibility"/"targetCompatibility":
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
or "toolchain":
java {
toolchain {
languageVersion = JavaLanguageVersion.of(8)
}
}
See:
https://docs.gradle.org/current/userguide/java_plugin.html#toolchain_and_compatibility
for more details.''';
printError(
'$indentation${errorMessage.split('\n').join('\n$indentation')}');
return false;
}
return true;
}
/// Returns whether the given gradle content is configured to enable all
/// Gradle-driven lints (those checked by ./gradlew lint) and treat them as
/// errors.
bool _validateGradleDrivenLintConfig(
RepositoryPackage package, List<String> gradleLines) {
final List<String> gradleBuildContents = package
.platformDirectory(FlutterPlatform.android)
.childFile('build.gradle')
.readAsLinesSync();
if (!gradleBuildContents.any((String line) =>
line.contains('checkAllWarnings true') && !_isCommented(line)) ||
!gradleBuildContents.any((String line) =>
line.contains('warningsAsErrors true') && !_isCommented(line))) {
printError('${indentation}This package is not configured to enable all '
'Gradle-driven lint warnings and treat them as errors. '
'Please add the following to the lintOptions section of '
'android/build.gradle:');
print('''
checkAllWarnings true
warningsAsErrors true
''');
return false;
}
return true;
}
/// Validates whether the given [example]'s gradle content is configured to
/// build its plugin target with javac lints enabled and treated as errors,
/// if the enclosing package is a plugin.
///
/// This can only be called on example packages. (Plugin packages should not
/// be configured this way, since it would affect clients.)
///
/// If [example]'s enclosing package is not a plugin package, this just
/// returns true.
bool _validateJavacLintConfig(
RepositoryPackage example, List<String> gradleLines) {
final RepositoryPackage enclosingPackage = example.getEnclosingPackage()!;
if (!pluginSupportsPlatform(platformAndroid, enclosingPackage,
requiredMode: PlatformSupport.inline)) {
return true;
}
final String enclosingPackageName = enclosingPackage.directory.basename;
// The check here is intentionally somewhat loose, to allow for the
// possibility of variations (e.g., not using Xlint:all in some cases, or
// passing other arguments).
if (!(gradleLines.any((String line) =>
line.contains('project(":$enclosingPackageName")')) &&
gradleLines.any((String line) =>
line.contains('options.compilerArgs') &&
line.contains('-Xlint') &&
line.contains('-Werror')))) {
printError('The example '
'"${getRelativePosixPath(example.directory, from: enclosingPackage.directory)}" '
'is not configured to treat javac lints and warnings as errors. '
'Please add the following to its build.gradle:');
print('''
gradle.projectsEvaluated {
project(":$enclosingPackageName") {
tasks.withType(JavaCompile) {
options.compilerArgs << "-Xlint:all" << "-Werror"
}
}
}
''');
return false;
}
return true;
}
/// Validates whether the given [example] has its Kotlin version set to at
/// least a minimum value, if it is set at all.
bool _validateKotlinVersion(
RepositoryPackage example, List<String> gradleLines) {
final RegExp kotlinVersionRegex =
RegExp(r"ext\.kotlin_version\s*=\s*'([\d.]+)'");
RegExpMatch? match;
if (gradleLines.any((String line) {
match = kotlinVersionRegex.firstMatch(line);
return match != null;
})) {
final Version version = Version.parse(match!.group(1)!);
if (version < minKotlinVersion) {
printError('build.gradle sets "ext.kotlin_version" to "$version". The '
'minimum Kotlin version that can be specified is '
'$minKotlinVersion, for compatibility with modern dependencies.');
return false;
}
}
return true;
}
}