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
5import 'dart:io';
6
7import 'package:path/path.dart' as path;
8
9import 'task_result.dart';
10import 'utils.dart';
11
12final 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
19final 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
25final List<String> baseApkFiles = <String>['classes.dex', 'AndroidManifest.xml'];
26
27/// Runs the given [testFunction] on a freshly generated Flutter project.
28Future<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.
42Future<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.
58Future<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.
77Future<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.
86Future<Iterable<String>> getFilesInAppBundle(String bundle) {
87 return getFilesInApk(bundle);
88}
89
90/// Returns the list of files inside an Android Archive.
91Future<Iterable<String>> getFilesInAar(String aar) {
92 return getFilesInApk(aar);
93}
94
95TaskResult 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
103bool hasMultipleOccurrences(String text, Pattern pattern) {
104 return text.indexOf(pattern) != text.lastIndexOf(pattern);
105}
106
107/// The Android home directory.
108String 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.
118Future<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.
166class 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`.
224Future<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.
230Future<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.
237Future<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.
247Future<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
256class 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
279android {
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 '''
315create("$name") {
316 applicationIdSuffix = ".$name"
317 versionNameSuffix = "-$name"
318}
319 ''';
320 })
321 .join('\n');
322
323 buildScript.openWrite(mode: FileMode.append).write('''
324android {
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
347flutter:
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
373class 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
411class 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
427Future<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
448Future<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.
490String? 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
510File 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