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 '../base/common.dart'; |
6 | import '../base/file_system.dart'; |
7 | import '../base/io.dart'; |
8 | import '../base/process.dart'; |
9 | import '../base/utils.dart'; |
10 | import '../base/version.dart'; |
11 | import '../convert.dart'; |
12 | import '../globals.dart' as globals; |
13 | import '../ios/plist_parser.dart'; |
14 | import 'android_studio_validator.dart'; |
15 | |
16 | // Android Studio layout: |
17 | |
18 | // Linux/Windows: |
19 | // $HOME/.AndroidStudioX.Y/system/.home |
20 | // $HOME/.cache/Google/AndroidStudioX.Y/.home |
21 | |
22 | // macOS: |
23 | // /Applications/Android Studio.app/Contents/ |
24 | // $HOME/Applications/Android Studio.app/Contents/ |
25 | |
26 | // Match Android Studio >= 4.1 base folder (AndroidStudio*.*) |
27 | // and < 4.1 (.AndroidStudio*.*) |
28 | final RegExp _dotHomeStudioVersionMatcher = |
29 | RegExp(r'^\.?(AndroidStudio[^\d]*)([\d.]+)' ); |
30 | |
31 | class AndroidStudio { |
32 | /// A [version] value of null represents an unknown version. |
33 | AndroidStudio( |
34 | this.directory, { |
35 | this.version, |
36 | this.configuredPath, |
37 | this.studioAppName = 'AndroidStudio' , |
38 | this.presetPluginsPath, |
39 | }) { |
40 | _initAndValidate(); |
41 | } |
42 | |
43 | static AndroidStudio? fromMacOSBundle( |
44 | String bundlePath, { |
45 | String? configuredPath, |
46 | }) { |
47 | final String studioPath = globals.fs.path.join(bundlePath, 'Contents' ); |
48 | final String plistFile = globals.fs.path.join(studioPath, 'Info.plist' ); |
49 | final Map<String, dynamic> plistValues = globals.plistParser.parseFile(plistFile); |
50 | // If we've found a JetBrainsToolbox wrapper, ignore it. |
51 | if (plistValues.containsKey('JetBrainsToolboxApp' )) { |
52 | return null; |
53 | } |
54 | |
55 | final String? versionString = plistValues[PlistParser.kCFBundleShortVersionStringKey] as String?; |
56 | |
57 | Version? version; |
58 | if (versionString != null) { |
59 | version = Version.parse(versionString); |
60 | } |
61 | |
62 | String? pathsSelectorValue; |
63 | final Map<String, dynamic>? jvmOptions = castStringKeyedMap(plistValues['JVMOptions' ]); |
64 | if (jvmOptions != null) { |
65 | final Map<String, dynamic>? jvmProperties = castStringKeyedMap(jvmOptions['Properties' ]); |
66 | if (jvmProperties != null) { |
67 | pathsSelectorValue = jvmProperties['idea.paths.selector' ] as String; |
68 | } |
69 | } |
70 | |
71 | final int? major = version?.major; |
72 | final int? minor = version?.minor; |
73 | String? presetPluginsPath; |
74 | final String? homeDirPath = globals.fsUtils.homeDirPath; |
75 | if (homeDirPath != null && pathsSelectorValue != null) { |
76 | if (major != null && major >= 4 && minor != null && minor >= 1) { |
77 | presetPluginsPath = globals.fs.path.join( |
78 | homeDirPath, |
79 | 'Library' , |
80 | 'Application Support' , |
81 | 'Google' , |
82 | pathsSelectorValue, |
83 | ); |
84 | } else { |
85 | presetPluginsPath = globals.fs.path.join( |
86 | homeDirPath, |
87 | 'Library' , |
88 | 'Application Support' , |
89 | pathsSelectorValue, |
90 | ); |
91 | } |
92 | } |
93 | return AndroidStudio( |
94 | studioPath, |
95 | version: version, |
96 | presetPluginsPath: presetPluginsPath, |
97 | configuredPath: configuredPath, |
98 | ); |
99 | } |
100 | |
101 | static AndroidStudio? fromHomeDot(Directory homeDotDir) { |
102 | final Match? versionMatch = |
103 | _dotHomeStudioVersionMatcher.firstMatch(homeDotDir.basename); |
104 | if (versionMatch?.groupCount != 2) { |
105 | return null; |
106 | } |
107 | final Version? version = Version.parse(versionMatch![2]); |
108 | final String? studioAppName = versionMatch[1]; |
109 | if (studioAppName == null || version == null) { |
110 | return null; |
111 | } |
112 | |
113 | final int major = version.major; |
114 | final int minor = version.minor; |
115 | |
116 | // The install path is written in a .home text file, |
117 | // it location is in /.home for Android Studio >= 4.1 |
118 | // and /system/.home for Android Studio < 4.1 |
119 | String dotHomeFilePath; |
120 | |
121 | if (major >= 4 && minor >= 1) { |
122 | dotHomeFilePath = globals.fs.path.join(homeDotDir.path, '.home' ); |
123 | } else { |
124 | dotHomeFilePath = |
125 | globals.fs.path.join(homeDotDir.path, 'system' , '.home' ); |
126 | } |
127 | |
128 | String? installPath; |
129 | |
130 | try { |
131 | installPath = globals.fs.file(dotHomeFilePath).readAsStringSync(); |
132 | } on Exception { |
133 | // ignored, installPath will be null, which is handled below |
134 | } |
135 | |
136 | if (installPath != null && globals.fs.isDirectorySync(installPath)) { |
137 | return AndroidStudio( |
138 | installPath, |
139 | version: version, |
140 | studioAppName: studioAppName, |
141 | ); |
142 | } |
143 | return null; |
144 | } |
145 | |
146 | final String directory; |
147 | final String studioAppName; |
148 | |
149 | /// The version of Android Studio. |
150 | /// |
151 | /// A null value represents an unknown version. |
152 | final Version? version; |
153 | |
154 | final String? configuredPath; |
155 | final String? presetPluginsPath; |
156 | |
157 | String? _javaPath; |
158 | bool _isValid = false; |
159 | final List<String> _validationMessages = <String>[]; |
160 | |
161 | /// The path of the JDK bundled with Android Studio. |
162 | /// |
163 | /// This will be null if the bundled JDK could not be found or run. |
164 | /// |
165 | /// If you looking to invoke the java binary or add it to the system |
166 | /// environment variables, consider using the [Java] class instead. |
167 | String? get javaPath => _javaPath; |
168 | |
169 | bool get isValid => _isValid; |
170 | |
171 | String? get pluginsPath { |
172 | if (presetPluginsPath != null) { |
173 | return presetPluginsPath!; |
174 | } |
175 | |
176 | // TODO(andrewkolos): This is a bug. We shouldn't treat an unknown |
177 | // version as equivalent to 0.0. |
178 | // See https://github.com/flutter/flutter/issues/121468. |
179 | final int major = version?.major ?? 0; |
180 | final int minor = version?.minor ?? 0; |
181 | final String? homeDirPath = globals.fsUtils.homeDirPath; |
182 | if (homeDirPath == null) { |
183 | return null; |
184 | } |
185 | if (globals.platform.isMacOS) { |
186 | /// plugin path of Android Studio has been changed after version 4.1. |
187 | if (major >= 4 && minor >= 1) { |
188 | return globals.fs.path.join( |
189 | homeDirPath, |
190 | 'Library' , |
191 | 'Application Support' , |
192 | 'Google' , |
193 | 'AndroidStudio $major. $minor' , |
194 | ); |
195 | } else { |
196 | return globals.fs.path.join( |
197 | homeDirPath, |
198 | 'Library' , |
199 | 'Application Support' , |
200 | 'AndroidStudio $major. $minor' , |
201 | ); |
202 | } |
203 | } else { |
204 | // JetBrains Toolbox write plugins here |
205 | final String toolboxPluginsPath = ' $directory.plugins' ; |
206 | |
207 | if (globals.fs.directory(toolboxPluginsPath).existsSync()) { |
208 | return toolboxPluginsPath; |
209 | } |
210 | |
211 | if (major >= 4 && minor >= 1 && |
212 | globals.platform.isLinux) { |
213 | return globals.fs.path.join( |
214 | homeDirPath, |
215 | '.local' , |
216 | 'share' , |
217 | 'Google' , |
218 | ' $studioAppName$major. $minor' , |
219 | ); |
220 | } |
221 | |
222 | return globals.fs.path.join( |
223 | homeDirPath, |
224 | '. $studioAppName$major. $minor' , |
225 | 'config' ,
|
226 | 'plugins' ,
|
227 | );
|
228 | }
|
229 | }
|
230 |
|
231 | List<String> get validationMessages => _validationMessages;
|
232 |
|
233 | /// Locates the newest, valid version of Android Studio.
|
234 | ///
|
235 | /// In the case that `--android-studio-dir` is configured, the version of
|
236 | /// Android Studio found at that location is always returned, even if it is
|
237 | /// invalid.
|
238 | static AndroidStudio? latestValid() {
|
239 | final Directory? configuredStudioDir = _configuredDir();
|
240 |
|
241 | // Find all available Studio installations.
|
242 | final List<AndroidStudio> studios = allInstalled();
|
243 | if (studios.isEmpty) {
|
244 | return null;
|
245 | }
|
246 |
|
247 | final AndroidStudio? manuallyConfigured = studios
|
248 | .where((AndroidStudio studio) => studio.configuredPath != null &&
|
249 | configuredStudioDir != null &&
|
250 | _pathsAreEqual(studio.configuredPath!, configuredStudioDir.path))
|
251 | .firstOrNull;
|
252 |
|
253 | if (manuallyConfigured != null) {
|
254 | return manuallyConfigured;
|
255 | }
|
256 |
|
257 | AndroidStudio? newest;
|
258 | for (final AndroidStudio studio in studios.where((AndroidStudio s) => s.isValid)) {
|
259 | if (newest == null) {
|
260 | newest = studio;
|
261 | continue;
|
262 | }
|
263 |
|
264 | // We prefer installs with known versions.
|
265 | if (studio.version != null && newest.version == null) {
|
266 | newest = studio;
|
267 | } else if (studio.version != null && newest.version != null &&
|
268 | studio.version! > newest.version!) {
|
269 | newest = studio;
|
270 | } else if (studio.version == null && newest.version == null &&
|
271 | studio.directory.compareTo(newest.directory) > 0) {
|
272 | newest = studio;
|
273 | }
|
274 | }
|
275 |
|
276 | return newest;
|
277 | }
|
278 |
|
279 | static List<AndroidStudio> allInstalled() =>
|
280 | globals.platform.isMacOS ? _allMacOS() : _allLinuxOrWindows();
|
281 |
|
282 | static List<AndroidStudio> _allMacOS() {
|
283 | final List<FileSystemEntity> candidatePaths = <FileSystemEntity>[];
|
284 |
|
285 | void checkForStudio(String path) {
|
286 | if (!globals.fs.isDirectorySync(path)) {
|
287 | return;
|
288 | }
|
289 | try {
|
290 | final Iterable<Directory> directories = globals.fs
|
291 | .directory(path)
|
292 | .listSync(followLinks: false)
|
293 | .whereType<Directory>();
|
294 | for (final Directory directory in directories) {
|
295 | final String name = directory.basename;
|
296 | // An exact match, or something like 'Android Studio 3.0 Preview.app'.
|
297 | if (name.startsWith('Android Studio' ) && name.endsWith('.app' )) {
|
298 | candidatePaths.add(directory);
|
299 | } else if (!directory.path.endsWith('.app' )) {
|
300 | checkForStudio(directory.path);
|
301 | }
|
302 | }
|
303 | } on Exception catch (e) {
|
304 | globals.printTrace('Exception while looking for Android Studio: $e' );
|
305 | }
|
306 | }
|
307 |
|
308 | checkForStudio('/Applications' );
|
309 | final String? homeDirPath = globals.fsUtils.homeDirPath;
|
310 | if (homeDirPath != null) {
|
311 | checkForStudio(globals.fs.path.join(
|
312 | homeDirPath,
|
313 | 'Applications' ,
|
314 | ));
|
315 | }
|
316 |
|
317 | Directory? configuredStudioDir = _configuredDir();
|
318 | if (configuredStudioDir != null) {
|
319 | if (configuredStudioDir.basename == 'Contents' ) {
|
320 | configuredStudioDir = configuredStudioDir.parent;
|
321 | }
|
322 | if (!candidatePaths
|
323 | .any((FileSystemEntity e) => _pathsAreEqual(e.path, configuredStudioDir!.path))) {
|
324 | candidatePaths.add(configuredStudioDir);
|
325 | }
|
326 | }
|
327 |
|
328 | // Query Spotlight for unexpected installation locations.
|
329 | String spotlightQueryResult = '' ;
|
330 | try {
|
331 | final ProcessResult spotlightResult = globals.processManager.runSync(<String>[
|
332 | 'mdfind' ,
|
333 | // com.google.android.studio, com.google.android.studio-EAP
|
334 | 'kMDItemCFBundleIdentifier="com.google.android.studio*"' ,
|
335 | ]);
|
336 | spotlightQueryResult = spotlightResult.stdout as String;
|
337 | } on ProcessException {
|
338 | // The Spotlight query is a nice-to-have, continue checking known installation locations.
|
339 | }
|
340 | for (final String studioPath in LineSplitter.split(spotlightQueryResult)) {
|
341 | final Directory appBundle = globals.fs.directory(studioPath);
|
342 | if (!candidatePaths.any((FileSystemEntity e) => e.path == studioPath)) {
|
343 | candidatePaths.add(appBundle);
|
344 | }
|
345 | }
|
346 |
|
347 | return candidatePaths
|
348 | .map<AndroidStudio?>((FileSystemEntity e) {
|
349 | if (configuredStudioDir == null) {
|
350 | return AndroidStudio.fromMacOSBundle(e.path);
|
351 | }
|
352 |
|
353 | return AndroidStudio.fromMacOSBundle(
|
354 | e.path,
|
355 | configuredPath: _pathsAreEqual(configuredStudioDir.path, e.path) ? configuredStudioDir.path : null,
|
356 | );
|
357 | })
|
358 | .whereType<AndroidStudio>()
|
359 | .toList();
|
360 | }
|
361 |
|
362 | static List<AndroidStudio> _allLinuxOrWindows() {
|
363 | final List<AndroidStudio> studios = <AndroidStudio>[];
|
364 |
|
365 | bool alreadyFoundStudioAt(String path, { Version? newerThan }) {
|
366 | return studios.any((AndroidStudio studio) {
|
367 | if (studio.directory != path) {
|
368 | return false;
|
369 | }
|
370 | if (newerThan != null) {
|
371 | if (studio.version == null) {
|
372 | return false;
|
373 | }
|
374 | return studio.version!.compareTo(newerThan) >= 0;
|
375 | }
|
376 | return true;
|
377 | });
|
378 | }
|
379 |
|
380 | // Read all $HOME/.AndroidStudio*/system/.home
|
381 | // or $HOME/.cache/Google/AndroidStudio*/.home files.
|
382 | // There may be several pointing to the same installation,
|
383 | // so we grab only the latest one.
|
384 | final String? homeDirPath = globals.fsUtils.homeDirPath;
|
385 |
|
386 | if (homeDirPath != null && globals.fs.directory(homeDirPath).existsSync()) {
|
387 | // >=4.1 has new install location at $HOME/.cache/Google
|
388 | final String cacheDirPath = globals.fs.path.join(homeDirPath, '.cache' , 'Google' );
|
389 | final List<Directory> directoriesToSearch = <Directory>[
|
390 | globals.fs.directory(homeDirPath),
|
391 | if (globals.fs.isDirectorySync(cacheDirPath)) globals.fs.directory(cacheDirPath),
|
392 | ];
|
393 |
|
394 | final List<Directory> entities = <Directory>[];
|
395 |
|
396 | for (final Directory baseDir in directoriesToSearch) {
|
397 | final Iterable<Directory> directories =
|
398 | baseDir.listSync(followLinks: false).whereType<Directory>();
|
399 | entities.addAll(directories.where((Directory directory) =>
|
400 | _dotHomeStudioVersionMatcher.hasMatch(directory.basename)));
|
401 | }
|
402 |
|
403 | for (final Directory entity in entities) {
|
404 | final AndroidStudio? studio = AndroidStudio.fromHomeDot(entity);
|
405 | if (studio != null && !alreadyFoundStudioAt(studio.directory, newerThan: studio.version)) {
|
406 | studios.removeWhere((AndroidStudio other) => other.directory == studio.directory);
|
407 | studios.add(studio);
|
408 | }
|
409 | }
|
410 | }
|
411 |
|
412 | // Discover Android Studio > 4.1
|
413 | if (globals.platform.isWindows && globals.platform.environment.containsKey('LOCALAPPDATA' )) {
|
414 | final Directory cacheDir = globals.fs.directory(globals.fs.path.join(globals.platform.environment['LOCALAPPDATA' ]!, 'Google' ));
|
415 | if (!cacheDir.existsSync()) {
|
416 | return studios;
|
417 | }
|
418 | for (final Directory dir in cacheDir.listSync().whereType<Directory>()) {
|
419 | final String name = globals.fs.path.basename(dir.path);
|
420 | AndroidStudioValidator.idToTitle.forEach((String id, String title) {
|
421 | if (name.startsWith(id)) {
|
422 | final String version = name.substring(id.length);
|
423 | String? installPath;
|
424 |
|
425 | try {
|
426 | installPath = globals.fs.file(globals.fs.path.join(dir.path, '.home' )).readAsStringSync();
|
427 | } on FileSystemException {
|
428 | // ignored
|
429 | }
|
430 | if (installPath != null && globals.fs.isDirectorySync(installPath)) {
|
431 | final AndroidStudio studio = AndroidStudio(
|
432 | installPath,
|
433 | version: Version.parse(version),
|
434 | studioAppName: title,
|
435 | );
|
436 | if (!alreadyFoundStudioAt(studio.directory, newerThan: studio.version)) {
|
437 | studios.removeWhere((AndroidStudio other) => _pathsAreEqual(other.directory, studio.directory));
|
438 | studios.add(studio);
|
439 | }
|
440 | }
|
441 | }
|
442 | });
|
443 | }
|
444 | }
|
445 |
|
446 | final String? configuredStudioDir = globals.config.getValue('android-studio-dir' ) as String?;
|
447 | if (configuredStudioDir != null) {
|
448 | final AndroidStudio? matchingAlreadyFoundInstall = studios
|
449 | .where((AndroidStudio other) => _pathsAreEqual(configuredStudioDir, other.directory))
|
450 | .firstOrNull;
|
451 | if (matchingAlreadyFoundInstall != null) {
|
452 | studios.remove(matchingAlreadyFoundInstall);
|
453 | studios.add(
|
454 | AndroidStudio(
|
455 | configuredStudioDir,
|
456 | configuredPath: configuredStudioDir,
|
457 | version: matchingAlreadyFoundInstall.version,
|
458 | ),
|
459 | );
|
460 | } else {
|
461 | studios.add(AndroidStudio(configuredStudioDir,
|
462 | configuredPath: configuredStudioDir));
|
463 | }
|
464 | }
|
465 |
|
466 | if (globals.platform.isLinux) {
|
467 | void checkWellKnownPath(String path) {
|
468 | if (globals.fs.isDirectorySync(path) && !alreadyFoundStudioAt(path)) {
|
469 | studios.add(AndroidStudio(path));
|
470 | }
|
471 | }
|
472 |
|
473 | // Add /opt/android-studio and $HOME/android-studio, if they exist.
|
474 | checkWellKnownPath('/opt/android-studio' );
|
475 | checkWellKnownPath(' ${globals.fsUtils.homeDirPath}/android-studio' );
|
476 | }
|
477 | return studios;
|
478 | }
|
479 |
|
480 | /// Gets the Android Studio install directory set by the user, if it is configured.
|
481 | ///
|
482 | /// The returned [Directory], if not null, is guaranteed to have existed during
|
483 | /// this function's execution.
|
484 | static Directory? _configuredDir() {
|
485 | final String? configuredPath = globals.config.getValue('android-studio-dir' ) as String?;
|
486 | if (configuredPath == null) {
|
487 | return null;
|
488 | }
|
489 | final Directory result = globals.fs.directory(configuredPath);
|
490 |
|
491 | bool? configuredStudioPathExists;
|
492 | String? exceptionMessage;
|
493 | try {
|
494 | configuredStudioPathExists = result.existsSync();
|
495 | } on FileSystemException catch (e) {
|
496 | exceptionMessage = e.toString();
|
497 | }
|
498 |
|
499 | if (configuredStudioPathExists == false || exceptionMessage != null) {
|
500 | throwToolExit('''
|
501 | Could not find the Android Studio installation at the manually configured path " $configuredPath".
|
502 | ${exceptionMessage == null ? '' : 'Encountered exception: $exceptionMessage\n\n' }
|
503 | Please verify that the path is correct and update it by running this command: flutter config --android-studio-dir '<path>'
|
504 | To have flutter search for Android Studio installations automatically, remove
|
505 | the configured path by running this command: flutter config --android-studio-dir
|
506 | ''' );
|
507 | }
|
508 |
|
509 | return result;
|
510 | }
|
511 |
|
512 | static String? extractStudioPlistValueWithMatcher(String plistValue, RegExp keyMatcher) {
|
513 | return keyMatcher.stringMatch(plistValue)?.split('=' ).last.trim().replaceAll('"' , '' );
|
514 | }
|
515 |
|
516 | void _initAndValidate() {
|
517 | _isValid = false;
|
518 | _validationMessages.clear();
|
519 |
|
520 | if (configuredPath != null) {
|
521 | _validationMessages.add('android-studio-dir = $configuredPath' );
|
522 | }
|
523 |
|
524 | if (!globals.fs.isDirectorySync(directory)) {
|
525 | _validationMessages.add('Android Studio not found at $directory' );
|
526 | return;
|
527 | }
|
528 |
|
529 | final String javaPath;
|
530 | if (globals.platform.isMacOS) {
|
531 | if (version != null && version!.major < 2020) {
|
532 | javaPath = globals.fs.path.join(directory, 'jre' , 'jdk' , 'Contents' , 'Home' );
|
533 | } else if (version != null && version!.major < 2022) {
|
534 | javaPath = globals.fs.path.join(directory, 'jre' , 'Contents' , 'Home' );
|
535 | // See https://github.com/flutter/flutter/issues/125246 for more context.
|
536 | } else {
|
537 | javaPath = globals.fs.path.join(directory, 'jbr' , 'Contents' , 'Home' );
|
538 | }
|
539 | } else {
|
540 | if (version != null && version!.major < 2022) {
|
541 | javaPath = globals.fs.path.join(directory, 'jre' );
|
542 | } else {
|
543 | javaPath = globals.fs.path.join(directory, 'jbr' );
|
544 | }
|
545 | }
|
546 | final String javaExecutable = globals.fs.path.join(javaPath, 'bin' , 'java' );
|
547 | if (!globals.processManager.canRun(javaExecutable)) {
|
548 | _validationMessages.add('Unable to find bundled Java version.' );
|
549 | } else {
|
550 | RunResult? result;
|
551 | try {
|
552 | result = globals.processUtils.runSync(<String>[javaExecutable, '-version' ]);
|
553 | } on ProcessException catch (e) {
|
554 | _validationMessages.add('Failed to run Java: $e' );
|
555 | }
|
556 | if (result != null && result.exitCode == 0) {
|
557 | final List<String> versionLines = result.stderr.split('\n' );
|
558 | final String javaVersion = versionLines.length >= 2 ? versionLines[1] : versionLines[0];
|
559 | _validationMessages.add('Java version $javaVersion' );
|
560 | _javaPath = javaPath;
|
561 | _isValid = true;
|
562 | } else {
|
563 | _validationMessages.add('Unable to determine bundled Java version.' );
|
564 | }
|
565 | }
|
566 | }
|
567 |
|
568 | @override
|
569 | String toString() => 'Android Studio ( $version)' ;
|
570 | }
|
571 |
|
572 | bool _pathsAreEqual(String path, String other) {
|
573 | return globals.fs.path.canonicalize(path) == globals.fs.path.canonicalize(other);
|
574 | }
|
575 |
|