1// Copyright (c) 2016 The Chromium 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 5import 'dart:async'; 6import 'dart:convert'; 7import 'dart:io'; 8 9import 'package:path/path.dart' as path; 10import 'package:flutter_devicelab/framework/framework.dart'; 11import 'package:flutter_devicelab/framework/utils.dart'; 12 13/// Runs the given [testFunction] on a freshly generated Flutter project. 14Future<void> runProjectTest(Future<void> testFunction(FlutterProject project)) async { 15 final Directory tempDir = Directory.systemTemp.createTempSync('flutter_devicelab_gradle_plugin_test.'); 16 final FlutterProject project = await FlutterProject.create(tempDir, 'hello'); 17 18 try { 19 await testFunction(project); 20 } finally { 21 rmTree(tempDir); 22 } 23} 24 25/// Runs the given [testFunction] on a freshly generated Flutter plugin project. 26Future<void> runPluginProjectTest(Future<void> testFunction(FlutterPluginProject pluginProject)) async { 27 final Directory tempDir = Directory.systemTemp.createTempSync('flutter_devicelab_gradle_plugin_test.'); 28 final FlutterPluginProject pluginProject = await FlutterPluginProject.create(tempDir, 'aaa'); 29 30 try { 31 await testFunction(pluginProject); 32 } finally { 33 rmTree(tempDir); 34 } 35} 36 37/// Returns the list of files inside an Android Package Kit. 38Future<Iterable<String>> getFilesInApk(String apk) async { 39 if (!File(apk).existsSync()) 40 throw TaskResult.failure( 41 'Gradle did not produce an output artifact file at: $apk'); 42 43 final Process unzip = await startProcess( 44 'unzip', 45 <String>['-v', apk], 46 isBot: false, // we just want to test the output, not have any debugging info 47 ); 48 return unzip.stdout 49 .transform(utf8.decoder) 50 .transform(const LineSplitter()) 51 .map((String line) => line.split(' ').last) 52 .toList(); 53} 54/// Returns the list of files inside an Android App Bundle. 55Future<Iterable<String>> getFilesInAppBundle(String bundle) { 56 return getFilesInApk(bundle); 57} 58 59/// Returns the list of files inside an Android Archive. 60Future<Iterable<String>> getFilesInAar(String aar) { 61 return getFilesInApk(aar); 62} 63 64void checkItContains<T>(Iterable<T> values, Iterable<T> collection) { 65 for (T value in values) { 66 if (!collection.contains(value)) { 67 throw TaskResult.failure('Expected to find `$value` in `$collection`.'); 68 } 69 } 70} 71 72void checkItDoesNotContain<T>(Iterable<T> values, Iterable<T> collection) { 73 for (T value in values) { 74 if (collection.contains(value)) { 75 throw TaskResult.failure('Did not expect to find `$value` in `$collection`.'); 76 } 77 } 78} 79 80TaskResult failure(String message, ProcessResult result) { 81 print('Unexpected process result:'); 82 print('Exit code: ${result.exitCode}'); 83 print('Std out :\n${result.stdout}'); 84 print('Std err :\n${result.stderr}'); 85 return TaskResult.failure(message); 86} 87 88bool hasMultipleOccurrences(String text, Pattern pattern) { 89 return text.indexOf(pattern) != text.lastIndexOf(pattern); 90} 91 92/// The Android home directory. 93String get _androidHome { 94 final String androidHome = Platform.environment['ANDROID_HOME'] ?? 95 Platform.environment['ANDROID_SDK_ROOT']; 96 if (androidHome == null || androidHome.isEmpty) { 97 throw Exception('Unset env flag: `ANDROID_HOME` or `ANDROID_SDK_ROOT`.'); 98 } 99 return androidHome; 100} 101 102/// Utility class to analyze the content inside an APK using dexdump, 103/// which is provided by the Android SDK. 104/// https://android.googlesource.com/platform/art/+/master/dexdump/dexdump.cc 105class ApkExtractor { 106 ApkExtractor(this.apkFile); 107 108 /// The APK. 109 final File apkFile; 110 111 bool _extracted = false; 112 113 Directory _outputDir; 114 115 Future<void> _extractApk() async { 116 if (_extracted) { 117 return; 118 } 119 _outputDir = apkFile.parent.createTempSync('apk'); 120 if (Platform.isWindows) { 121 await eval('7za', <String>['x', apkFile.path], workingDirectory: _outputDir.path); 122 } else { 123 await eval('unzip', <String>[apkFile.path], workingDirectory: _outputDir.path); 124 } 125 _extracted = true; 126 } 127 128 /// Returns the full path to the [dexdump] tool. 129 Future<String> _findDexDump() async { 130 String dexdumps; 131 if (Platform.isWindows) { 132 dexdumps = await eval('dir', <String>['/s/b', 'dexdump.exe'], 133 workingDirectory: _androidHome); 134 } else { 135 dexdumps = await eval('find', <String>[_androidHome, '-name', 'dexdump']); 136 } 137 if (dexdumps.isEmpty) { 138 throw Exception('Couldn\'t find a dexdump executable.'); 139 } 140 return dexdumps.split('\n').first; 141 } 142 143 // Removes any temporary directory. 144 void dispose() { 145 if (!_extracted) { 146 return; 147 } 148 rmTree(_outputDir); 149 _extracted = true; 150 } 151 152 /// Returns true if the APK contains a given class. 153 Future<bool> containsClass(String className) async { 154 await _extractApk(); 155 156 final String dexDump = await _findDexDump(); 157 final String classesDex = path.join(_outputDir.path, 'classes.dex'); 158 159 if (!File(classesDex).existsSync()) { 160 throw Exception('Couldn\'t find classes.dex in the APK.'); 161 } 162 final String classDescriptors = await eval(dexDump, 163 <String>[classesDex], printStdout: false); 164 165 if (classDescriptors.isEmpty) { 166 throw Exception('No descriptors found in classes.dex.'); 167 } 168 return classDescriptors.contains(className.replaceAll('.', '/')); 169 } 170} 171 172/// Gets the content of the `AndroidManifest.xml`. 173Future<String> getAndroidManifest(String apk) { 174 final String apkAnalyzer = path.join(_androidHome, 'tools', 'bin', 'apkanalyzer'); 175 return eval(apkAnalyzer, <String>['manifest', 'print', apk], 176 workingDirectory: _androidHome); 177} 178 179 /// Checks that the classes are contained in the APK, throws otherwise. 180Future<void> checkApkContainsClasses(File apk, List<String> classes) async { 181 final ApkExtractor extractor = ApkExtractor(apk); 182 for (String className in classes) { 183 if (!(await extractor.containsClass(className))) { 184 throw Exception('APK doesn\'t contain class `$className`.'); 185 } 186 } 187 extractor.dispose(); 188} 189 190class FlutterProject { 191 FlutterProject(this.parent, this.name); 192 193 final Directory parent; 194 final String name; 195 196 static Future<FlutterProject> create(Directory directory, String name) async { 197 await inDirectory(directory, () async { 198 await flutter('create', options: <String>['--template=app', name]); 199 }); 200 return FlutterProject(directory, name); 201 } 202 203 String get rootPath => path.join(parent.path, name); 204 String get androidPath => path.join(rootPath, 'android'); 205 206 Future<void> addCustomBuildType(String name, {String initWith}) async { 207 final File buildScript = File( 208 path.join(androidPath, 'app', 'build.gradle'), 209 ); 210 211 buildScript.openWrite(mode: FileMode.append).write(''' 212 213android { 214 buildTypes { 215 $name { 216 initWith $initWith 217 } 218 } 219} 220 '''); 221 } 222 223 Future<void> addGlobalBuildType(String name, {String initWith}) async { 224 final File buildScript = File( 225 path.join(androidPath, 'build.gradle'), 226 ); 227 228 buildScript.openWrite(mode: FileMode.append).write(''' 229subprojects { 230 afterEvaluate { 231 android { 232 buildTypes { 233 $name { 234 initWith $initWith 235 } 236 } 237 } 238 } 239} 240 '''); 241 } 242 243 Future<void> addPlugin(String plugin) async { 244 final File pubspec = File(path.join(rootPath, 'pubspec.yaml')); 245 String content = await pubspec.readAsString(); 246 content = content.replaceFirst( 247 '\ndependencies:\n', 248 '\ndependencies:\n $plugin:\n', 249 ); 250 await pubspec.writeAsString(content, flush: true); 251 } 252 253 Future<void> getPackages() async { 254 await inDirectory(Directory(rootPath), () async { 255 await flutter('pub', options: <String>['get']); 256 }); 257 } 258 259 Future<void> addProductFlavors(Iterable<String> flavors) async { 260 final File buildScript = File( 261 path.join(androidPath, 'app', 'build.gradle'), 262 ); 263 264 final String flavorConfig = flavors.map((String name) { 265 return ''' 266$name { 267 applicationIdSuffix ".$name" 268 versionNameSuffix "-$name" 269} 270 '''; 271 }).join('\n'); 272 273 buildScript.openWrite(mode: FileMode.append).write(''' 274android { 275 flavorDimensions "mode" 276 productFlavors { 277 $flavorConfig 278 } 279} 280 '''); 281 } 282 283 Future<void> introduceError() async { 284 final File buildScript = File( 285 path.join(androidPath, 'app', 'build.gradle'), 286 ); 287 await buildScript.writeAsString((await buildScript.readAsString()).replaceAll('buildTypes', 'builTypes')); 288 } 289 290 Future<void> runGradleTask(String task, {List<String> options}) async { 291 return _runGradleTask(workingDirectory: androidPath, task: task, options: options); 292 } 293 294 Future<ProcessResult> resultOfGradleTask(String task, {List<String> options}) { 295 return _resultOfGradleTask(workingDirectory: androidPath, task: task, options: options); 296 } 297 298 Future<ProcessResult> resultOfFlutterCommand(String command, List<String> options) { 299 return Process.run( 300 path.join(flutterDirectory.path, 'bin', 'flutter'), 301 <String>[command, ...options], 302 workingDirectory: rootPath, 303 ); 304 } 305} 306 307class FlutterPluginProject { 308 FlutterPluginProject(this.parent, this.name); 309 310 final Directory parent; 311 final String name; 312 313 static Future<FlutterPluginProject> create(Directory directory, String name) async { 314 await inDirectory(directory, () async { 315 await flutter('create', options: <String>['--template=plugin', name]); 316 }); 317 return FlutterPluginProject(directory, name); 318 } 319 320 String get rootPath => path.join(parent.path, name); 321 String get examplePath => path.join(rootPath, 'example'); 322 String get exampleAndroidPath => path.join(examplePath, 'android'); 323 String get debugApkPath => path.join(examplePath, 'build', 'app', 'outputs', 'apk', 'debug', 'app-debug.apk'); 324 String get releaseApkPath => path.join(examplePath, 'build', 'app', 'outputs', 'apk', 'release', 'app-release.apk'); 325 String get releaseArmApkPath => path.join(examplePath, 'build', 'app', 'outputs', 'apk', 'release', 'app-armeabi-v7a-release.apk'); 326 String get releaseArm64ApkPath => path.join(examplePath, 'build', 'app', 'outputs', 'apk', 'release', 'app-arm64-v8a-release.apk'); 327 String get releaseBundlePath => path.join(examplePath, 'build', 'app', 'outputs', 'bundle', 'release', 'app.aab'); 328 329 Future<void> runGradleTask(String task, {List<String> options}) async { 330 return _runGradleTask(workingDirectory: exampleAndroidPath, task: task, options: options); 331 } 332} 333 334Future<void> _runGradleTask({String workingDirectory, String task, List<String> options}) async { 335 final ProcessResult result = await _resultOfGradleTask( 336 workingDirectory: workingDirectory, 337 task: task, 338 options: options); 339 if (result.exitCode != 0) { 340 print('stdout:'); 341 print(result.stdout); 342 print('stderr:'); 343 print(result.stderr); 344 } 345 if (result.exitCode != 0) 346 throw 'Gradle exited with error'; 347} 348 349Future<ProcessResult> _resultOfGradleTask({String workingDirectory, String task, 350 List<String> options}) async { 351 section('Find Java'); 352 final String javaHome = await findJavaHome(); 353 354 if (javaHome == null) 355 throw TaskResult.failure('Could not find Java'); 356 357 print('\nUsing JAVA_HOME=$javaHome'); 358 359 final List<String> args = <String>[ 360 'app:$task', 361 ...?options, 362 ]; 363 final String gradle = path.join(workingDirectory, Platform.isWindows ? 'gradlew.bat' : './gradlew'); 364 print('┌── $gradle'); 365 print('│ ' + File(path.join(workingDirectory, gradle)).readAsLinesSync().join('\n│ ')); 366 print('└─────────────────────────────────────────────────────────────────────────────────────'); 367 print( 368 'Running Gradle:\n' 369 ' Executable: $gradle\n' 370 ' Arguments: ${args.join(' ')}\n' 371 ' Working directory: $workingDirectory\n' 372 ' JAVA_HOME: $javaHome\n' 373 '' 374 ); 375 return Process.run( 376 gradle, 377 args, 378 workingDirectory: workingDirectory, 379 environment: <String, String>{ 'JAVA_HOME': javaHome }, 380 ); 381} 382 383class _Dependencies { 384 _Dependencies(String depfilePath) { 385 final RegExp _separatorExpr = RegExp(r'([^\\]) '); 386 final RegExp _escapeExpr = RegExp(r'\\(.)'); 387 388 // Depfile format: 389 // outfile1 outfile2 : file1.dart file2.dart file3.dart file\ 4.dart 390 final String contents = File(depfilePath).readAsStringSync(); 391 final List<String> colonSeparated = contents.split(': '); 392 target = colonSeparated[0].trim(); 393 dependencies = colonSeparated[1] 394 // Put every file on right-hand side on the separate line 395 .replaceAllMapped(_separatorExpr, (Match match) => '${match.group(1)}\n') 396 .split('\n') 397 // Expand escape sequences, so that '\ ', for example,ß becomes ' ' 398 .map<String>((String path) => path.replaceAllMapped(_escapeExpr, (Match match) => match.group(1)).trim()) 399 .where((String path) => path.isNotEmpty) 400 .toSet(); 401 } 402 403 String target; 404 Set<String> dependencies; 405} 406 407/// Returns [null] if target matches [expectedTarget], otherwise returns an error message. 408String validateSnapshotDependency(FlutterProject project, String expectedTarget) { 409 final _Dependencies deps = _Dependencies( 410 path.join(project.rootPath, 'build', 'app', 'intermediates', 411 'flutter', 'debug', 'android-arm', 'snapshot_blob.bin.d')); 412 return deps.target == expectedTarget ? null : 413 'Dependency file should have $expectedTarget as target. Instead has ${deps.target}'; 414} 415