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