| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | import 'dart:io'; |
| 6 | |
| 7 | import 'package:path/path.dart' as path; |
| 8 | |
| 9 | import 'task_result.dart'; |
| 10 | import 'utils.dart'; |
| 11 | |
| 12 | final List<String> flutterAssets = <String>[ |
| 13 | 'assets/flutter_assets/AssetManifest.json' , |
| 14 | 'assets/flutter_assets/NOTICES.Z' , |
| 15 | 'assets/flutter_assets/fonts/MaterialIcons-Regular.otf' , |
| 16 | 'assets/flutter_assets/packages/cupertino_icons/assets/CupertinoIcons.ttf' , |
| 17 | ]; |
| 18 | |
| 19 | final List<String> debugAssets = <String>[ |
| 20 | 'assets/flutter_assets/isolate_snapshot_data' , |
| 21 | 'assets/flutter_assets/kernel_blob.bin' , |
| 22 | 'assets/flutter_assets/vm_snapshot_data' , |
| 23 | ]; |
| 24 | |
| 25 | final List<String> baseApkFiles = <String>['classes.dex' , 'AndroidManifest.xml' ]; |
| 26 | |
| 27 | /// Runs the given [testFunction] on a freshly generated Flutter project. |
| 28 | Future<void> runProjectTest(Future<void> Function(FlutterProject project) testFunction) async { |
| 29 | final Directory tempDir = Directory.systemTemp.createTempSync( |
| 30 | 'flutter_devicelab_gradle_plugin_test.' , |
| 31 | ); |
| 32 | final FlutterProject project = await FlutterProject.create(tempDir, 'hello' ); |
| 33 | |
| 34 | try { |
| 35 | await testFunction(project); |
| 36 | } finally { |
| 37 | rmTree(tempDir); |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | /// Runs the given [testFunction] on a freshly generated Flutter plugin project. |
| 42 | Future<void> runPluginProjectTest( |
| 43 | Future<void> Function(FlutterPluginProject pluginProject) testFunction, |
| 44 | ) async { |
| 45 | final Directory tempDir = Directory.systemTemp.createTempSync( |
| 46 | 'flutter_devicelab_gradle_plugin_test.' , |
| 47 | ); |
| 48 | final FlutterPluginProject pluginProject = await FlutterPluginProject.create(tempDir, 'aaa' ); |
| 49 | |
| 50 | try { |
| 51 | await testFunction(pluginProject); |
| 52 | } finally { |
| 53 | rmTree(tempDir); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /// Runs the given [testFunction] on a freshly generated Flutter module project. |
| 58 | Future<void> runModuleProjectTest( |
| 59 | Future<void> Function(FlutterModuleProject moduleProject) testFunction, |
| 60 | ) async { |
| 61 | final Directory tempDir = Directory.systemTemp.createTempSync( |
| 62 | 'flutter_devicelab_gradle_module_test.' , |
| 63 | ); |
| 64 | final FlutterModuleProject moduleProject = await FlutterModuleProject.create( |
| 65 | tempDir, |
| 66 | 'hello_module' , |
| 67 | ); |
| 68 | |
| 69 | try { |
| 70 | await testFunction(moduleProject); |
| 71 | } finally { |
| 72 | rmTree(tempDir); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// Returns the list of files inside an Android Package Kit. |
| 77 | Future<Iterable<String>> getFilesInApk(String apk) async { |
| 78 | if (!File(apk).existsSync()) { |
| 79 | throw TaskResult.failure('Gradle did not produce an output artifact file at: $apk' ); |
| 80 | } |
| 81 | final String files = await _evalApkAnalyzer(<String>['files' , 'list' , apk]); |
| 82 | return files.split('\n' ).map((String file) => file.substring(1).trim()); |
| 83 | } |
| 84 | |
| 85 | /// Returns the list of files inside an Android App Bundle. |
| 86 | Future<Iterable<String>> getFilesInAppBundle(String bundle) { |
| 87 | return getFilesInApk(bundle); |
| 88 | } |
| 89 | |
| 90 | /// Returns the list of files inside an Android Archive. |
| 91 | Future<Iterable<String>> getFilesInAar(String aar) { |
| 92 | return getFilesInApk(aar); |
| 93 | } |
| 94 | |
| 95 | TaskResult failure(String message, ProcessResult result) { |
| 96 | print('Unexpected process result:' ); |
| 97 | print('Exit code: ${result.exitCode}' ); |
| 98 | print('Std out :\n ${result.stdout}' ); |
| 99 | print('Std err :\n ${result.stderr}' ); |
| 100 | return TaskResult.failure(message); |
| 101 | } |
| 102 | |
| 103 | bool hasMultipleOccurrences(String text, Pattern pattern) { |
| 104 | return text.indexOf(pattern) != text.lastIndexOf(pattern); |
| 105 | } |
| 106 | |
| 107 | /// The Android home directory. |
| 108 | String get _androidHome { |
| 109 | final String? androidHome = |
| 110 | Platform.environment['ANDROID_HOME' ] ?? Platform.environment['ANDROID_SDK_ROOT' ]; |
| 111 | if (androidHome == null || androidHome.isEmpty) { |
| 112 | throw Exception('Environment variable `ANDROID_HOME` is not set.' ); |
| 113 | } |
| 114 | return androidHome; |
| 115 | } |
| 116 | |
| 117 | /// Executes an APK analyzer subcommand. |
| 118 | Future<String> _evalApkAnalyzer( |
| 119 | List<String> args, { |
| 120 | bool printStdout = false, |
| 121 | String? workingDirectory, |
| 122 | }) async { |
| 123 | final String? javaHome = await findJavaHome(); |
| 124 | if (javaHome == null || javaHome.isEmpty) { |
| 125 | throw Exception('No JAVA_HOME set.' ); |
| 126 | } |
| 127 | final String apkAnalyzer = path.join( |
| 128 | _androidHome, |
| 129 | 'cmdline-tools' , |
| 130 | 'latest' , |
| 131 | 'bin' , |
| 132 | Platform.isWindows ? 'apkanalyzer.bat' : 'apkanalyzer' , |
| 133 | ); |
| 134 | if (canRun(apkAnalyzer)) { |
| 135 | return eval( |
| 136 | apkAnalyzer, |
| 137 | args, |
| 138 | printStdout: printStdout, |
| 139 | workingDirectory: workingDirectory, |
| 140 | environment: <String, String>{'JAVA_HOME' : javaHome}, |
| 141 | ); |
| 142 | } |
| 143 | |
| 144 | final String javaBinary = path.join(javaHome, 'bin' , 'java' ); |
| 145 | assert(canRun(javaBinary)); |
| 146 | final String androidTools = path.join(_androidHome, 'tools' ); |
| 147 | final String libs = path.join(androidTools, 'lib' ); |
| 148 | assert(Directory(libs).existsSync()); |
| 149 | |
| 150 | final String classSeparator = Platform.isWindows ? ';' : ':' ; |
| 151 | return eval( |
| 152 | javaBinary, |
| 153 | <String>[ |
| 154 | '-Dcom.android.sdklib.toolsdir= $androidTools' , |
| 155 | '-classpath' , |
| 156 | '. $classSeparator$libs${Platform.pathSeparator}*' , |
| 157 | 'com.android.tools.apk.analyzer.ApkAnalyzerCli' ,
|
| 158 | ...args,
|
| 159 | ],
|
| 160 | printStdout: printStdout,
|
| 161 | workingDirectory: workingDirectory,
|
| 162 | );
|
| 163 | }
|
| 164 |
|
| 165 | /// Utility class to analyze the content inside an APK using the APK analyzer.
|
| 166 | class ApkExtractor {
|
| 167 | ApkExtractor(this.apkFile);
|
| 168 |
|
| 169 | /// The APK.
|
| 170 | final File apkFile;
|
| 171 |
|
| 172 | bool _extracted = false;
|
| 173 |
|
| 174 | Set<String> _classes = const <String>{};
|
| 175 | Set<String> _methods = const <String>{};
|
| 176 |
|
| 177 | Future<void> _extractDex() async {
|
| 178 | if (_extracted) {
|
| 179 | return;
|
| 180 | }
|
| 181 | final String packages = await _evalApkAnalyzer(<String>['dex' , 'packages' , apkFile.path]);
|
| 182 | final List<String> lines = packages.split('\n' );
|
| 183 | _classes = Set<String>.from(
|
| 184 | lines
|
| 185 | .where((String line) => line.startsWith('C' ))
|
| 186 | .map<String>((String line) => line.split('\t' ).last),
|
| 187 | );
|
| 188 | assert(_classes.isNotEmpty);
|
| 189 | _methods = Set<String>.from(
|
| 190 | lines
|
| 191 | .where((String line) => line.startsWith('M' ))
|
| 192 | .map<String>((String line) => line.split('\t' ).last),
|
| 193 | );
|
| 194 | assert(_methods.isNotEmpty);
|
| 195 | _extracted = true;
|
| 196 | }
|
| 197 |
|
| 198 | /// Returns true if APK contains classes from library with given [libraryName].
|
| 199 | Future<bool> containsLibrary(String libraryName) async {
|
| 200 | await _extractDex();
|
| 201 | for (final String className in _classes) {
|
| 202 | if (className.startsWith(libraryName)) {
|
| 203 | return true;
|
| 204 | }
|
| 205 | }
|
| 206 | return false;
|
| 207 | }
|
| 208 |
|
| 209 | /// Returns true if the APK contains a given class.
|
| 210 | Future<bool> containsClass(String className) async {
|
| 211 | await _extractDex();
|
| 212 | return _classes.contains(className);
|
| 213 | }
|
| 214 |
|
| 215 | /// Returns true if the APK contains a given method.
|
| 216 | /// For example: io.flutter.plugins.googlemaps.GoogleMapController void onFlutterViewAttached(android.view.View)
|
| 217 | Future<bool> containsMethod(String methodName) async {
|
| 218 | await _extractDex();
|
| 219 | return _methods.contains(methodName);
|
| 220 | }
|
| 221 | }
|
| 222 |
|
| 223 | /// Gets the content of the `AndroidManifest.xml`.
|
| 224 | Future<String> getAndroidManifest(String apk) async {
|
| 225 | return _evalApkAnalyzer(<String>['manifest' , 'print' , apk], workingDirectory: _androidHome);
|
| 226 | }
|
| 227 |
|
| 228 | /// Checks that the [apk] includes any classes from a particularly library with
|
| 229 | /// given [libraryName] in the [apk] and returns true if so, false otherwise.
|
| 230 | Future<bool> checkApkContainsMethodsFromLibrary(File apk, String libraryName) async {
|
| 231 | final ApkExtractor extractor = ApkExtractor(apk);
|
| 232 | final bool apkContainsMethodsFromLibrary = await extractor.containsLibrary(libraryName);
|
| 233 | return apkContainsMethodsFromLibrary;
|
| 234 | }
|
| 235 |
|
| 236 | /// Checks that the classes are contained in the APK, throws otherwise.
|
| 237 | Future<void> checkApkContainsClasses(File apk, List<String> classes) async {
|
| 238 | final ApkExtractor extractor = ApkExtractor(apk);
|
| 239 | for (final String className in classes) {
|
| 240 | if (!(await extractor.containsClass(className))) {
|
| 241 | throw Exception("APK doesn't contain class ` $className`." );
|
| 242 | }
|
| 243 | }
|
| 244 | }
|
| 245 |
|
| 246 | /// Checks that the methods are defined in the APK, throws otherwise.
|
| 247 | Future<void> checkApkContainsMethods(File apk, List<String> methods) async {
|
| 248 | final ApkExtractor extractor = ApkExtractor(apk);
|
| 249 | for (final String method in methods) {
|
| 250 | if (!(await extractor.containsMethod(method))) {
|
| 251 | throw Exception("APK doesn't contain method ` $method`." );
|
| 252 | }
|
| 253 | }
|
| 254 | }
|
| 255 |
|
| 256 | class FlutterProject {
|
| 257 | FlutterProject(this.parent, this.name);
|
| 258 |
|
| 259 | final Directory parent;
|
| 260 | final String name;
|
| 261 |
|
| 262 | static Future<FlutterProject> create(Directory directory, String name) async {
|
| 263 | await inDirectory(directory, () async {
|
| 264 | await flutter('create' , options: <String>['--template=app' , name]);
|
| 265 | });
|
| 266 | return FlutterProject(directory, name);
|
| 267 | }
|
| 268 |
|
| 269 | String get rootPath => path.join(parent.path, name);
|
| 270 | String get androidPath => path.join(rootPath, 'android' );
|
| 271 | String get iosPath => path.join(rootPath, 'ios' );
|
| 272 | File get appBuildFile => getAndroidBuildFile(path.join(androidPath, 'app' ));
|
| 273 |
|
| 274 | Future<void> addCustomBuildType(String name, {required String initWith}) async {
|
| 275 | final File buildScript = appBuildFile;
|
| 276 |
|
| 277 | buildScript.openWrite(mode: FileMode.append).write('''
|
| 278 |
|
| 279 | android {
|
| 280 | buildTypes {
|
| 281 | create(" $name") {
|
| 282 | initWith(getByName(" $initWith"))
|
| 283 | }
|
| 284 | }
|
| 285 | }
|
| 286 | ''' );
|
| 287 | }
|
| 288 |
|
| 289 | /// Adds a plugin to the pubspec.
|
| 290 | ///
|
| 291 | /// If a particular version of the [plugin] is desired, it should be included
|
| 292 | /// in the name as it would be in the `flutter pub add` command, e.g.
|
| 293 | /// `google_maps_flutter:^2.2.1`.
|
| 294 | ///
|
| 295 | /// Include all other desired options for running `flutter pub add` to
|
| 296 | /// [options], e.g. `<String>['--path', 'path/to/plugin']`.
|
| 297 | Future<void> addPlugin(String plugin, {List<String> options = const <String>[]}) async {
|
| 298 | await inDirectory(Directory(rootPath), () async {
|
| 299 | await flutter('pub' , options: <String>['add' , plugin, ...options]);
|
| 300 | });
|
| 301 | }
|
| 302 |
|
| 303 | Future<void> getPackages() async {
|
| 304 | await inDirectory(Directory(rootPath), () async {
|
| 305 | await flutter('pub' , options: <String>['get' ]);
|
| 306 | });
|
| 307 | }
|
| 308 |
|
| 309 | Future<void> addProductFlavors(Iterable<String> flavors) async {
|
| 310 | final File buildScript = appBuildFile;
|
| 311 |
|
| 312 | final String flavorConfig = flavors
|
| 313 | .map((String name) {
|
| 314 | return '''
|
| 315 | create(" $name") {
|
| 316 | applicationIdSuffix = ". $name"
|
| 317 | versionNameSuffix = "- $name"
|
| 318 | }
|
| 319 | ''' ;
|
| 320 | })
|
| 321 | .join('\n' );
|
| 322 |
|
| 323 | buildScript.openWrite(mode: FileMode.append).write('''
|
| 324 | android {
|
| 325 | flavorDimensions.add("mode")
|
| 326 | productFlavors {
|
| 327 | $flavorConfig
|
| 328 | }
|
| 329 | }
|
| 330 | ''' );
|
| 331 | }
|
| 332 |
|
| 333 | Future<void> introduceError() async {
|
| 334 | final File buildScript = appBuildFile;
|
| 335 | await buildScript.writeAsString(
|
| 336 | (await buildScript.readAsString()).replaceAll('buildTypes' , 'builTypes' ),
|
| 337 | );
|
| 338 | }
|
| 339 |
|
| 340 | Future<void> introducePubspecError() async {
|
| 341 | final File pubspec = File(path.join(parent.path, 'hello' , 'pubspec.yaml' ));
|
| 342 | final String contents = pubspec.readAsStringSync();
|
| 343 | final String newContents = contents.replaceFirst(
|
| 344 | ' ${Platform.lineTerminator}flutter: ${Platform.lineTerminator}' ,
|
| 345 | '''
|
| 346 |
|
| 347 | flutter:
|
| 348 | assets:
|
| 349 | - lib/gallery/example_code.dart
|
| 350 |
|
| 351 | ''' ,
|
| 352 | );
|
| 353 | pubspec.writeAsStringSync(newContents);
|
| 354 | }
|
| 355 |
|
| 356 | Future<void> runGradleTask(String task, {List<String>? options}) async {
|
| 357 | return _runGradleTask(workingDirectory: androidPath, task: task, options: options);
|
| 358 | }
|
| 359 |
|
| 360 | Future<ProcessResult> resultOfGradleTask(String task, {List<String>? options}) {
|
| 361 | return _resultOfGradleTask(workingDirectory: androidPath, task: task, options: options);
|
| 362 | }
|
| 363 |
|
| 364 | Future<ProcessResult> resultOfFlutterCommand(String command, List<String> options) {
|
| 365 | return Process.run(
|
| 366 | path.join(flutterDirectory.path, 'bin' , Platform.isWindows ? 'flutter.bat' : 'flutter' ),
|
| 367 | <String>[command, ...options],
|
| 368 | workingDirectory: rootPath,
|
| 369 | );
|
| 370 | }
|
| 371 | }
|
| 372 |
|
| 373 | class FlutterPluginProject {
|
| 374 | FlutterPluginProject(this.parent, this.name);
|
| 375 |
|
| 376 | final Directory parent;
|
| 377 | final String name;
|
| 378 |
|
| 379 | static Future<FlutterPluginProject> create(
|
| 380 | Directory directory,
|
| 381 | String name, {
|
| 382 | List<String> options = const <String>['--platforms=ios,android' ],
|
| 383 | }) async {
|
| 384 | await inDirectory(directory, () async {
|
| 385 | await flutter('create' , options: <String>['--template=plugin' , ...options, name]);
|
| 386 | });
|
| 387 | return FlutterPluginProject(directory, name);
|
| 388 | }
|
| 389 |
|
| 390 | String get rootPath => path.join(parent.path, name);
|
| 391 | String get examplePath => path.join(rootPath, 'example' );
|
| 392 | String get exampleAndroidPath => path.join(examplePath, 'android' );
|
| 393 | String get debugApkPath =>
|
| 394 | path.join(examplePath, 'build' , 'app' , 'outputs' , 'flutter-apk' , 'app-debug.apk' );
|
| 395 | String get releaseApkPath =>
|
| 396 | path.join(examplePath, 'build' , 'app' , 'outputs' , 'flutter-apk' , 'app-release.apk' );
|
| 397 | String get releaseArmApkPath => path.join(
|
| 398 | examplePath,
|
| 399 | 'build' ,
|
| 400 | 'app' ,
|
| 401 | 'outputs' ,
|
| 402 | 'flutter-apk' ,
|
| 403 | 'app-armeabi-v7a-release.apk' ,
|
| 404 | );
|
| 405 | String get releaseArm64ApkPath =>
|
| 406 | path.join(examplePath, 'build' , 'app' , 'outputs' , 'flutter-apk' , 'app-arm64-v8a-release.apk' );
|
| 407 | String get releaseBundlePath =>
|
| 408 | path.join(examplePath, 'build' , 'app' , 'outputs' , 'bundle' , 'release' , 'app.aab' );
|
| 409 | }
|
| 410 |
|
| 411 | class FlutterModuleProject {
|
| 412 | FlutterModuleProject(this.parent, this.name);
|
| 413 |
|
| 414 | final Directory parent;
|
| 415 | final String name;
|
| 416 |
|
| 417 | static Future<FlutterModuleProject> create(Directory directory, String name) async {
|
| 418 | await inDirectory(directory, () async {
|
| 419 | await flutter('create' , options: <String>['--template=module' , name]);
|
| 420 | });
|
| 421 | return FlutterModuleProject(directory, name);
|
| 422 | }
|
| 423 |
|
| 424 | String get rootPath => path.join(parent.path, name);
|
| 425 | }
|
| 426 |
|
| 427 | Future<void> _runGradleTask({
|
| 428 | required String workingDirectory,
|
| 429 | required String task,
|
| 430 | List<String>? options,
|
| 431 | }) async {
|
| 432 | final ProcessResult result = await _resultOfGradleTask(
|
| 433 | workingDirectory: workingDirectory,
|
| 434 | task: task,
|
| 435 | options: options,
|
| 436 | );
|
| 437 | if (result.exitCode != 0) {
|
| 438 | print('stdout:' );
|
| 439 | print(result.stdout);
|
| 440 | print('stderr:' );
|
| 441 | print(result.stderr);
|
| 442 | }
|
| 443 | if (result.exitCode != 0) {
|
| 444 | throw 'Gradle exited with error' ;
|
| 445 | }
|
| 446 | }
|
| 447 |
|
| 448 | Future<ProcessResult> _resultOfGradleTask({
|
| 449 | required String workingDirectory,
|
| 450 | required String task,
|
| 451 | List<String>? options,
|
| 452 | }) async {
|
| 453 | section('Find Java' );
|
| 454 | final String? javaHome = await findJavaHome();
|
| 455 |
|
| 456 | if (javaHome == null) {
|
| 457 | throw TaskResult.failure('Could not find Java' );
|
| 458 | }
|
| 459 |
|
| 460 | print('\nUsing JAVA_HOME= $javaHome' );
|
| 461 |
|
| 462 | final List<String> args = <String>['app: $task' , ...?options];
|
| 463 | final String gradle = path.join(
|
| 464 | workingDirectory,
|
| 465 | Platform.isWindows ? 'gradlew.bat' : './gradlew' ,
|
| 466 | );
|
| 467 | print('┌── $gradle' );
|
| 468 | print(
|
| 469 | File(
|
| 470 | path.join(workingDirectory, gradle),
|
| 471 | ).readAsLinesSync().map((String line) => '| $line' ).join('\n' ),
|
| 472 | );
|
| 473 | print('└─────────────────────────────────────────────────────────────────────────────────────' );
|
| 474 | print(
|
| 475 | 'Running Gradle:\n'
|
| 476 | ' Executable: $gradle\n'
|
| 477 | ' Arguments: ${args.join(' ' )}\n'
|
| 478 | ' Working directory: $workingDirectory\n'
|
| 479 | ' JAVA_HOME: $javaHome\n' ,
|
| 480 | );
|
| 481 | return Process.run(
|
| 482 | gradle,
|
| 483 | args,
|
| 484 | workingDirectory: workingDirectory,
|
| 485 | environment: <String, String>{'JAVA_HOME' : javaHome},
|
| 486 | );
|
| 487 | }
|
| 488 |
|
| 489 | /// Returns [null] if target matches [expectedTarget], otherwise returns an error message.
|
| 490 | String? validateSnapshotDependency(FlutterProject project, String expectedTarget) {
|
| 491 | final File snapshotBlob = File(
|
| 492 | path.join(
|
| 493 | project.rootPath,
|
| 494 | 'build' ,
|
| 495 | 'app' ,
|
| 496 | 'intermediates' ,
|
| 497 | 'flutter' ,
|
| 498 | 'debug' ,
|
| 499 | 'flutter_build.d' ,
|
| 500 | ),
|
| 501 | );
|
| 502 |
|
| 503 | assert(snapshotBlob.existsSync());
|
| 504 | final String contentSnapshot = snapshotBlob.readAsStringSync();
|
| 505 | return contentSnapshot.contains(' $expectedTarget ' )
|
| 506 | ? null
|
| 507 | : 'Dependency file should have $expectedTarget as target. Instead found $contentSnapshot' ;
|
| 508 | }
|
| 509 |
|
| 510 | File getAndroidBuildFile(String androidAppPath, {bool settings = false}) {
|
| 511 | final File groovyFile = File(
|
| 512 | path.join(androidAppPath, settings ? 'settings.gradle' : 'build.gradle' ),
|
| 513 | );
|
| 514 | final File kotlinFile = File(
|
| 515 | path.join(androidAppPath, settings ? 'settings.gradle.kts' : 'build.gradle.kts' ),
|
| 516 | );
|
| 517 |
|
| 518 | if (groovyFile.existsSync()) {
|
| 519 | return groovyFile;
|
| 520 | }
|
| 521 |
|
| 522 | return kotlinFile;
|
| 523 | }
|
| 524 |
|