From 21ae908f6d95fc1d569593b0c1f10af8f0c6a4b8 Mon Sep 17 00:00:00 2001 From: Camille Simon <43054281+camsim99@users.noreply.github.com> Date: Tue, 31 Oct 2023 22:15:01 +0000 Subject: [PATCH] [tool] Add option for Android compile SDK version to update-dependencies command (#5010) Adds option to `update-dependencies` command to update the compile SDK version of plugins or their example apps. --- .../lib/src/common/repository_package.dart | 12 + .../lib/src/update_dependency_command.dart | 176 ++++++--- .../test/common/repository_package_test.dart | 10 + .../test/update_dependency_command_test.dart | 370 ++++++++++++++++-- 4 files changed, 480 insertions(+), 88 deletions(-) diff --git a/script/tool/lib/src/common/repository_package.dart b/script/tool/lib/src/common/repository_package.dart index 345ace50e5ca..a32424e30cfe 100644 --- a/script/tool/lib/src/common/repository_package.dart +++ b/script/tool/lib/src/common/repository_package.dart @@ -142,6 +142,18 @@ class RepositoryPackage { !isPlatformInterface && directory.basename != directory.parent.basename; + /// True if this appears to be an example package, according to package + /// conventions. + bool get isExample { + final RepositoryPackage? enclosingPackage = getEnclosingPackage(); + if (enclosingPackage == null) { + // An example package is enclosed in another package. + return false; + } + // Check whether this is one of the enclosing package's examples. + return enclosingPackage.getExamples().any((RepositoryPackage p) => p.path == path); + } + /// Returns the Flutter example packages contained in the package, if any. Iterable getExamples() { final Directory exampleDirectory = directory.childDirectory('example'); diff --git a/script/tool/lib/src/update_dependency_command.dart b/script/tool/lib/src/update_dependency_command.dart index 185abb57fad6..548465c5f338 100644 --- a/script/tool/lib/src/update_dependency_command.dart +++ b/script/tool/lib/src/update_dependency_command.dart @@ -42,10 +42,17 @@ class UpdateDependencyCommand extends PackageLoopingCommand { argParser.addOption(_androidDependency, help: 'An Android dependency to update.', allowed: [ - 'gradle', + _AndroidDepdencyType.gradle, + _AndroidDepdencyType.compileSdk, + _AndroidDepdencyType.compileSdkForExamples, ], allowedHelp: { - 'gradle': 'Updates Gradle version used in plugin example apps.', + _AndroidDepdencyType.gradle: + 'Updates Gradle version used in plugin example apps.', + _AndroidDepdencyType.compileSdk: + 'Updates compileSdk version used to compile plugins.', + _AndroidDepdencyType.compileSdkForExamples: + 'Updates compileSdk version used to compile plugin examples.', }); argParser.addOption( _versionFlag, @@ -130,7 +137,7 @@ ${response.httpResponse.body} if (version == null) { printError('A version must be provided to update this dependency.'); throw ToolExit(_exitNoTargetVersion); - } else if (_targetAndroidDependency == 'gradle') { + } else if (_targetAndroidDependency == _AndroidDepdencyType.gradle) { final RegExp validGradleVersionPattern = RegExp(r'^\d+(?:\.\d+){1,2}$'); final bool isValidGradleVersion = validGradleVersionPattern.stringMatch(version) == version; @@ -139,14 +146,24 @@ ${response.httpResponse.body} 'A version with a valid format (maximum 2-3 numbers separated by period) must be provided.'); throw ToolExit(_exitInvalidTargetVersion); } - _targetVersion = version; - return; + } else if (_targetAndroidDependency == _AndroidDepdencyType.compileSdk || + _targetAndroidDependency == + _AndroidDepdencyType.compileSdkForExamples) { + final RegExp validSdkVersion = RegExp(r'^\d{1,2}$'); + final bool isValidSdkVersion = + validSdkVersion.stringMatch(version) == version; + if (!isValidSdkVersion) { + printError( + 'A valid Android SDK version number (1-2 digit numbers) must be provided.'); + throw ToolExit(_exitInvalidTargetVersion); + } } else { - // TODO(camsim99): Add other supported Android dependencies like the Android SDK and AGP. + // TODO(camsim99): Add other supported Android dependencies like the min/target Android SDK and AGP. printError( 'Target Android dependency $_targetAndroidDependency is unrecognized.'); throw ToolExit(_exitIncorrectTargetDependency); } + _targetVersion = version; } } @@ -233,59 +250,114 @@ ${response.httpResponse.body} /// an Android dependency. Future _runForAndroidDependency( RepositoryPackage package) async { - if (_targetAndroidDependency == 'gradle') { - final Iterable packageExamples = package.getExamples(); - bool updateRanForExamples = false; - for (final RepositoryPackage example in packageExamples) { - if (!example.platformDirectory(FlutterPlatform.android).existsSync()) { - continue; - } + if (_targetAndroidDependency == _AndroidDepdencyType.compileSdk) { + return _runForCompileSdkVersion(package); + } else if (_targetAndroidDependency == _AndroidDepdencyType.gradle || + _targetAndroidDependency == + _AndroidDepdencyType.compileSdkForExamples) { + return _runForAndroidDependencyOnExamples(package); + } - updateRanForExamples = true; - Directory gradleWrapperPropertiesDirectory = - example.platformDirectory(FlutterPlatform.android); - if (gradleWrapperPropertiesDirectory + return PackageResult.fail([ + 'Target Android dependency $_androidDependency is unrecognized.' + ]); + } + + Future _runForAndroidDependencyOnExamples( + RepositoryPackage package) async { + final Iterable packageExamples = package.getExamples(); + bool updateRanForExamples = false; + for (final RepositoryPackage example in packageExamples) { + if (!example.platformDirectory(FlutterPlatform.android).existsSync()) { + continue; + } + + updateRanForExamples = true; + Directory androidDirectory = + example.platformDirectory(FlutterPlatform.android); + final File fileToUpdate; + final RegExp dependencyVersionPattern; + final String newDependencyVersionEntry; + + if (_targetAndroidDependency == _AndroidDepdencyType.gradle) { + if (androidDirectory .childDirectory('app') .childDirectory('gradle') .existsSync()) { - gradleWrapperPropertiesDirectory = - gradleWrapperPropertiesDirectory.childDirectory('app'); + androidDirectory = androidDirectory.childDirectory('app'); } - final File gradleWrapperPropertiesFile = - gradleWrapperPropertiesDirectory - .childDirectory('gradle') - .childDirectory('wrapper') - .childFile('gradle-wrapper.properties'); - - final String gradleWrapperPropertiesContents = - gradleWrapperPropertiesFile.readAsStringSync(); - final RegExp validGradleDistributionUrl = + fileToUpdate = androidDirectory + .childDirectory('gradle') + .childDirectory('wrapper') + .childFile('gradle-wrapper.properties'); + dependencyVersionPattern = RegExp(r'^\s*distributionUrl\s*=\s*.*\.zip', multiLine: true); - if (!validGradleDistributionUrl - .hasMatch(gradleWrapperPropertiesContents)) { - return PackageResult.fail([ - 'Unable to find a "distributionUrl" entry to update for ${package.displayName}.' - ]); - } - - print( - '${indentation}Updating ${getRelativePosixPath(example.directory, from: package.directory)} to "$_targetVersion"'); - final String newGradleWrapperPropertiesContents = - gradleWrapperPropertiesContents.replaceFirst( - validGradleDistributionUrl, - 'distributionUrl=https\\://services.gradle.org/distributions/gradle-$_targetVersion-all.zip'); // TODO(camsim99): Validate current AGP version against target Gradle // version: https://github.com/flutter/flutter/issues/133887. - gradleWrapperPropertiesFile - .writeAsStringSync(newGradleWrapperPropertiesContents); + newDependencyVersionEntry = + 'distributionUrl=https\\://services.gradle.org/distributions/gradle-$_targetVersion-all.zip'; + } else if (_targetAndroidDependency == + _AndroidDepdencyType.compileSdkForExamples) { + fileToUpdate = + androidDirectory.childDirectory('app').childFile('build.gradle'); + dependencyVersionPattern = RegExp( + r'(compileSdk|compileSdkVersion) (\d{1,2}|flutter.compileSdkVersion)'); + newDependencyVersionEntry = 'compileSdk $_targetVersion'; + } else { + printError( + 'Target Android dependency $_targetAndroidDependency is unrecognized.'); + throw ToolExit(_exitIncorrectTargetDependency); + } + + final String oldFileToUpdateContents = fileToUpdate.readAsStringSync(); + + if (!dependencyVersionPattern.hasMatch(oldFileToUpdateContents)) { + return PackageResult.fail([ + 'Unable to find a $_targetAndroidDependency version entry to update for ${example.displayName}.' + ]); } - return updateRanForExamples - ? PackageResult.success() - : PackageResult.skip('No example apps run on Android.'); + + print( + '${indentation}Updating ${getRelativePosixPath(example.directory, from: package.directory)} to "$_targetVersion"'); + final String newGradleWrapperPropertiesContents = oldFileToUpdateContents + .replaceFirst(dependencyVersionPattern, newDependencyVersionEntry); + + fileToUpdate.writeAsStringSync(newGradleWrapperPropertiesContents); } - return PackageResult.fail([ - 'Target Android dependency $_androidDependency is unrecognized.' - ]); + return updateRanForExamples + ? PackageResult.success() + : PackageResult.skip('No example apps run on Android.'); + } + + Future _runForCompileSdkVersion( + RepositoryPackage package) async { + if (!package.platformDirectory(FlutterPlatform.android).existsSync()) { + return PackageResult.skip( + 'Package ${package.displayName} does not run on Android.'); + } else if (package.isExample) { + // We skip examples for this command. + return PackageResult.skip( + 'Package ${package.displayName} is not a top-level package; run with "compileSdkForExamples" to update.'); + } + final File buildConfigurationFile = package + .platformDirectory(FlutterPlatform.android) + .childFile('build.gradle'); + final String buildConfigurationContents = + buildConfigurationFile.readAsStringSync(); + final RegExp validCompileSdkVersion = + RegExp(r'(compileSdk|compileSdkVersion) \d{1,2}'); + + if (!validCompileSdkVersion.hasMatch(buildConfigurationContents)) { + return PackageResult.fail([ + 'Unable to find a compileSdk version entry to update for ${package.displayName}.' + ]); + } + print('${indentation}Updating ${package.directory} to "$_targetVersion"'); + final String newBuildConfigurationContents = buildConfigurationContents + .replaceFirst(validCompileSdkVersion, 'compileSdk $_targetVersion'); + buildConfigurationFile.writeAsStringSync(newBuildConfigurationContents); + + return PackageResult.success(); } /// Returns information about the current dependency of [package] on @@ -414,3 +486,9 @@ class _PubDependencyInfo { } enum _PubDependencyType { normal, dev } + +class _AndroidDepdencyType { + static const String gradle = 'gradle'; + static const String compileSdk = 'compileSdk'; + static const String compileSdkForExamples = 'compileSdkForExamples'; +} diff --git a/script/tool/test/common/repository_package_test.dart b/script/tool/test/common/repository_package_test.dart index db519c008233..c603453db0c2 100644 --- a/script/tool/test/common/repository_package_test.dart +++ b/script/tool/test/common/repository_package_test.dart @@ -102,6 +102,7 @@ void main() { final List examples = plugin.getExamples().toList(); expect(examples.length, 1); + expect(examples[0].isExample, isTrue); expect(examples[0].path, getExampleDir(plugin).path); }); @@ -112,6 +113,8 @@ void main() { final List examples = plugin.getExamples().toList(); expect(examples.length, 2); + expect(examples[0].isExample, isTrue); + expect(examples[1].isExample, isTrue); expect(examples[0].path, getExampleDir(plugin).childDirectory('example1').path); expect(examples[1].path, @@ -125,6 +128,7 @@ void main() { final List examples = package.getExamples().toList(); expect(examples.length, 1); + expect(examples[0].isExample, isTrue); expect(examples[0].path, getExampleDir(package).path); }); @@ -136,6 +140,8 @@ void main() { final List examples = package.getExamples().toList(); expect(examples.length, 2); + expect(examples[0].isExample, isTrue); + expect(examples[1].isExample, isTrue); expect(examples[0].path, getExampleDir(package).childDirectory('example1').path); expect(examples[1].path, @@ -151,6 +157,7 @@ void main() { expect(plugin.isAppFacing, false); expect(plugin.isPlatformInterface, false); expect(plugin.isFederated, false); + expect(plugin.isExample, isFalse); }); test('handle app-facing packages', () { @@ -160,6 +167,7 @@ void main() { expect(plugin.isAppFacing, true); expect(plugin.isPlatformInterface, false); expect(plugin.isPlatformImplementation, false); + expect(plugin.isExample, isFalse); }); test('handle platform interface packages', () { @@ -170,6 +178,7 @@ void main() { expect(plugin.isAppFacing, false); expect(plugin.isPlatformInterface, true); expect(plugin.isPlatformImplementation, false); + expect(plugin.isExample, isFalse); }); test('handle platform implementation packages', () { @@ -181,6 +190,7 @@ void main() { expect(plugin.isAppFacing, false); expect(plugin.isPlatformInterface, false); expect(plugin.isPlatformImplementation, true); + expect(plugin.isExample, isFalse); }); }); diff --git a/script/tool/test/update_dependency_command_test.dart b/script/tool/test/update_dependency_command_test.dart index 0fd3969210f6..deee0d629b87 100644 --- a/script/tool/test/update_dependency_command_test.dart +++ b/script/tool/test/update_dependency_command_test.dart @@ -690,7 +690,7 @@ How is it even possible that I didn't specify a Gradle distribution? output, containsAllInOrder([ contains( - 'Unable to find a "distributionUrl" entry to update for ${package.displayName}.'), + 'Unable to find a gradle version entry to update for ${package.displayName}/example.'), ]), ); }); @@ -779,30 +779,28 @@ distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip r'distributionUrl=https\://services.gradle.org/distributions/' 'gradle-$newGradleVersion-all.zip')); }); - }); - }); - test('succeeds if one example app runs on Android and another does not', - () async { - final RepositoryPackage package = createFakePlugin( - 'fake_plugin', packagesDir, examples: [ - 'example_1', - 'example_2' - ], extraFiles: [ - 'example/example_2/android/app/gradle/wrapper/gradle-wrapper.properties' - ]); - const String newGradleVersion = '8.8.8'; - - final File gradleWrapperPropertiesFile = package.directory - .childDirectory('example') - .childDirectory('example_2') - .childDirectory('android') - .childDirectory('app') - .childDirectory('gradle') - .childDirectory('wrapper') - .childFile('gradle-wrapper.properties'); - - gradleWrapperPropertiesFile.writeAsStringSync(r''' + test('succeeds if one example app runs on Android and another does not', + () async { + final RepositoryPackage package = createFakePlugin( + 'fake_plugin', packagesDir, examples: [ + 'example_1', + 'example_2' + ], extraFiles: [ + 'example/example_2/android/app/gradle/wrapper/gradle-wrapper.properties' + ]); + const String newGradleVersion = '8.8.8'; + + final File gradleWrapperPropertiesFile = package.directory + .childDirectory('example') + .childDirectory('example_2') + .childDirectory('android') + .childDirectory('app') + .childDirectory('gradle') + .childDirectory('wrapper') + .childFile('gradle-wrapper.properties'); + + gradleWrapperPropertiesFile.writeAsStringSync(r''' distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME @@ -810,21 +808,315 @@ zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip '''); - await runCapturingPrint(runner, [ - 'update-dependency', - '--packages', - package.displayName, - '--android-dependency', - 'gradle', - '--version', - newGradleVersion, - ]); + await runCapturingPrint(runner, [ + 'update-dependency', + '--packages', + package.displayName, + '--android-dependency', + 'gradle', + '--version', + newGradleVersion, + ]); - final String updatedGradleWrapperPropertiesContents = - gradleWrapperPropertiesFile.readAsStringSync(); - expect( - updatedGradleWrapperPropertiesContents, - contains(r'distributionUrl=https\://services.gradle.org/distributions/' - 'gradle-$newGradleVersion-all.zip')); + final String updatedGradleWrapperPropertiesContents = + gradleWrapperPropertiesFile.readAsStringSync(); + expect( + updatedGradleWrapperPropertiesContents, + contains( + r'distributionUrl=https\://services.gradle.org/distributions/' + 'gradle-$newGradleVersion-all.zip')); + }); + }); + + group('compileSdk/compileSdkForExamples', () { + // Tests if the compileSdk version is updated for the provided + // build.gradle file and new compileSdk version to update to. + Future testCompileSdkVersionUpdated( + {required RepositoryPackage package, + required File buildGradleFile, + required String oldCompileSdkVersion, + required String newCompileSdkVersion, + bool runForExamples = false, + bool checkForDeprecatedCompileSdkVersion = false}) async { + buildGradleFile.writeAsStringSync(''' +android { + // Conditional for compatibility with AGP <4.2. + if (project.android.hasProperty("namespace")) { + namespace 'io.flutter.plugins.pathprovider' + } + ${checkForDeprecatedCompileSdkVersion ? 'compileSdkVersion' : 'compileSdk'} $oldCompileSdkVersion +'''); + + await runCapturingPrint(runner, [ + 'update-dependency', + '--packages', + package.displayName, + '--android-dependency', + if (runForExamples) 'compileSdkForExamples' else 'compileSdk', + '--version', + newCompileSdkVersion, + ]); + + final String updatedBuildGradleContents = + buildGradleFile.readAsStringSync(); + // compileSdkVersion is now deprecated, so if the tool finds any + // instances of compileSdk OR compileSdkVersion, it should change it + // to compileSdk. See https://developer.android.com/reference/tools/gradle-api/7.2/com/android/build/api/dsl/CommonExtension#compileSdkVersion(kotlin.Int). + expect(updatedBuildGradleContents, + contains('compileSdk $newCompileSdkVersion')); + } + + test('throws if version format is invalid for compileSdk', () async { + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--android-dependency', + 'compileSdk', + '--version', + '834', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'A valid Android SDK version number (1-2 digit numbers) must be provided.'), + ]), + ); + }); + + test('throws if version format is invalid for compileSdkForExamples', + () async { + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--android-dependency', + 'compileSdkForExamples', + '--version', + '438', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'A valid Android SDK version number (1-2 digit numbers) must be provided.'), + ]), + ); + }); + + test('skips if plugin does not run on Android', () async { + final RepositoryPackage package = + createFakePlugin('fake_plugin', packagesDir); + + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--packages', + package.displayName, + '--android-dependency', + 'compileSdk', + '--version', + '34', + ]); + + expect( + output, + containsAllInOrder([ + contains( + 'SKIPPING: Package ${package.displayName} does not run on Android.'), + ]), + ); + }); + + test('skips if plugin example does not run on Android', () async { + final RepositoryPackage package = + createFakePlugin('fake_plugin', packagesDir); + + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--packages', + package.displayName, + '--android-dependency', + 'compileSdkForExamples', + '--version', + '34', + ]); + + expect( + output, + containsAllInOrder([ + contains('SKIPPING: No example apps run on Android.'), + ]), + ); + }); + + test( + 'throws if build configuration file does not have compileSdk version with expected format for compileSdk', + () async { + final RepositoryPackage package = createFakePlugin( + 'fake_plugin', packagesDir, + extraFiles: ['android/build.gradle']); + + final File buildGradleFile = package.directory + .childDirectory('android') + .childFile('build.gradle'); + + buildGradleFile.writeAsStringSync(''' +How is it even possible that I didn't specify a compileSdk version? +'''); + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--packages', + package.displayName, + '--android-dependency', + 'compileSdk', + '--version', + '34', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Unable to find a compileSdk version entry to update for ${package.displayName}.'), + ]), + ); + }); + + test( + 'throws if build configuration file does not have compileSdk version with expected format for compileSdkForExamples', + () async { + final RepositoryPackage package = createFakePlugin( + 'fake_plugin', packagesDir, + extraFiles: ['example/android/app/build.gradle']); + + final File buildGradleFile = package.directory + .childDirectory('example') + .childDirectory('android') + .childDirectory('app') + .childFile('build.gradle'); + + buildGradleFile.writeAsStringSync(''' +How is it even possible that I didn't specify a compileSdk version? +'''); + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--packages', + package.displayName, + '--android-dependency', + 'compileSdkForExamples', + '--version', + '34', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Unable to find a compileSdkForExamples version entry to update for ${package.displayName}/example.'), + ]), + ); + }); + + test( + 'succeeds if plugin runs on Android and valid version is supplied for compileSdkVersion entry', + () async { + final RepositoryPackage package = createFakePlugin( + 'fake_plugin', packagesDir, extraFiles: [ + 'android/build.gradle', + 'example/android/app/build.gradle' + ]); + final File buildGradleFile = package.directory + .childDirectory('android') + .childFile('build.gradle'); + + await testCompileSdkVersionUpdated( + package: package, + buildGradleFile: buildGradleFile, + oldCompileSdkVersion: '8', + newCompileSdkVersion: '16', + checkForDeprecatedCompileSdkVersion: true); + }); + + test( + 'succeeds if plugin example runs on Android and valid version is supplied for compileSdkVersion entry', + () async { + final RepositoryPackage package = createFakePlugin( + 'fake_plugin', packagesDir, extraFiles: [ + 'android/build.gradle', + 'example/android/app/build.gradle' + ]); + final File exampleBuildGradleFile = package.directory + .childDirectory('example') + .childDirectory('android') + .childDirectory('app') + .childFile('build.gradle'); + + await testCompileSdkVersionUpdated( + package: package, + buildGradleFile: exampleBuildGradleFile, + oldCompileSdkVersion: '8', + newCompileSdkVersion: '16', + runForExamples: true, + checkForDeprecatedCompileSdkVersion: true); + }); + + test( + 'succeeds if plugin runs on Android and valid version is supplied for compileSdk entry', + () async { + final RepositoryPackage package = createFakePlugin( + 'fake_plugin', packagesDir, extraFiles: [ + 'android/build.gradle', + 'example/android/app/build.gradle' + ]); + + final File buildGradleFile = package.directory + .childDirectory('android') + .childFile('build.gradle'); + await testCompileSdkVersionUpdated( + package: package, + buildGradleFile: buildGradleFile, + oldCompileSdkVersion: '8', + newCompileSdkVersion: '16'); + }); + + test( + 'succeeds if plugin example runs on Android and valid version is supplied for compileSdk entry', + () async { + final RepositoryPackage package = createFakePlugin( + 'fake_plugin', packagesDir, extraFiles: [ + 'android/build.gradle', + 'example/android/app/build.gradle' + ]); + + final File exampleBuildGradleFile = package.directory + .childDirectory('example') + .childDirectory('android') + .childDirectory('app') + .childFile('build.gradle'); + await testCompileSdkVersionUpdated( + package: package, + buildGradleFile: exampleBuildGradleFile, + oldCompileSdkVersion: '33', + newCompileSdkVersion: '34', + runForExamples: true); + }); + }); }); }