android_studio.dart 18.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

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 17 18 19

// Android Studio layout:

// Linux/Windows:
// $HOME/.AndroidStudioX.Y/system/.home
20
// $HOME/.cache/Google/AndroidStudioX.Y/.home
21 22 23 24 25

// macOS:
// /Applications/Android Studio.app/Contents/
// $HOME/Applications/Android Studio.app/Contents/

26 27
// Match Android Studio >= 4.1 base folder (AndroidStudio*.*)
// and < 4.1 (.AndroidStudio*.*)
28
final RegExp _dotHomeStudioVersionMatcher =
29
    RegExp(r'^\.?(AndroidStudio[^\d]*)([\d.]+)');
30

31 32
class AndroidStudio {
  /// A [version] value of null represents an unknown version.
33 34
  AndroidStudio(
    this.directory, {
35 36
    this.version,
    this.configuredPath,
37 38
    this.studioAppName = 'AndroidStudio',
    this.presetPluginsPath,
39 40
  }) {
    _initAndValidate();
41 42
  }

43 44 45 46
  static AndroidStudio? fromMacOSBundle(
    String bundlePath, {
    String? configuredPath,
  }) {
47 48 49 50 51 52
    final String studioPath = globals.fs.path.join(bundlePath, 'Contents');
    final String plistFile = globals.fs.path.join(studioPath, 'Info.plist');
    final Map<String, dynamic> plistValues = globals.plistParser.parseFile(plistFile);
    // If we've found a JetBrainsToolbox wrapper, ignore it.
    if (plistValues.containsKey('JetBrainsToolboxApp')) {
      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 63
    String? pathsSelectorValue;
    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 72 73 74 75
    final int? major = version?.major;
    final int? minor = version?.minor;
    String? presetPluginsPath;
    final String? homeDirPath = globals.fsUtils.homeDirPath;
    if (homeDirPath != null && pathsSelectorValue != null) {
76 77
      if (major != null && major >= 4 && minor != null && minor >= 1) {
        presetPluginsPath = globals.fs.path.join(
78
          homeDirPath,
79 80 81 82 83 84 85
          'Library',
          'Application Support',
          'Google',
          pathsSelectorValue,
        );
      } else {
        presetPluginsPath = globals.fs.path.join(
86
          homeDirPath,
87 88 89 90 91 92
          'Library',
          'Application Support',
          pathsSelectorValue,
        );
      }
    }
93 94 95 96 97 98
    return AndroidStudio(
      studioPath,
      version: version,
      presetPluginsPath: presetPluginsPath,
      configuredPath: configuredPath,
    );
99 100
  }

101 102
  static AndroidStudio? fromHomeDot(Directory homeDotDir) {
    final Match? versionMatch =
103 104 105 106
        _dotHomeStudioVersionMatcher.firstMatch(homeDotDir.basename);
    if (versionMatch?.groupCount != 2) {
      return null;
    }
107 108
    final Version? version = Version.parse(versionMatch![2]);
    final String? studioAppName = versionMatch[1];
109
    if (studioAppName == null || version == null) {
110 111
      return null;
    }
112

113 114
    final int major = version.major;
    final int minor = version.minor;
115 116 117 118 119 120

    // The install path is written in a .home text file,
    // it location is in <base dir>/.home for Android Studio >= 4.1
    // and <base dir>/system/.home for Android Studio < 4.1
    String dotHomeFilePath;

121
    if (major >= 4 && minor >= 1) {
122 123 124 125 126 127
      dotHomeFilePath = globals.fs.path.join(homeDotDir.path, '.home');
    } else {
      dotHomeFilePath =
          globals.fs.path.join(homeDotDir.path, 'system', '.home');
    }

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 138 139 140 141
      return AndroidStudio(
          installPath,
          version: version,
          studioAppName: studioAppName,
      );
142 143 144 145
    }
    return null;
  }

146 147
  final String directory;
  final String studioAppName;
148 149 150 151 152 153 154

  /// The version of Android Studio.
  ///
  /// A null value represents an unknown version.
  final Version? version;

  final String? configuredPath;
155
  final String? presetPluginsPath;
156

157
  String? _javaPath;
158 159 160
  bool _isValid = false;
  final List<String> _validationMessages = <String>[];

161 162 163
  /// The path of the JDK bundled with Android Studio.
  ///
  /// This will be null if the bundled JDK could not be found or run.
164 165 166
  ///
  /// If you looking to invoke the java binary or add it to the system
  /// environment variables, consider using the [Java] class instead.
167
  String? get javaPath => _javaPath;
168

169 170
  bool get isValid => _isValid;

171
  String? get pluginsPath {
172
    if (presetPluginsPath != null) {
173 174
      return presetPluginsPath!;
    }
175 176 177 178 179 180

    // TODO(andrewkolos): This is a bug. We shouldn't treat an unknown
    // version as equivalent to 0.0.
    // See https://github.com/flutter/flutter/issues/121468.
    final int major = version?.major ?? 0;
    final int minor = version?.minor ?? 0;
181 182 183
    final String? homeDirPath = globals.fsUtils.homeDirPath;
    if (homeDirPath == null) {
      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 191 192 193 194 195 196
          'Library',
          'Application Support',
          'Google',
          'AndroidStudio$major.$minor',
        );
      } else {
        return globals.fs.path.join(
197
          homeDirPath,
198 199 200 201 202
          'Library',
          'Application Support',
          'AndroidStudio$major.$minor',
        );
      }
203
    } else {
204 205 206 207 208 209 210
      // JetBrains Toolbox write plugins here
      final String toolboxPluginsPath = '$directory.plugins';

      if (globals.fs.directory(toolboxPluginsPath).existsSync()) {
        return toolboxPluginsPath;
      }

211
      if (major >= 4 && minor >= 1 &&
212 213
          globals.platform.isLinux) {
        return globals.fs.path.join(
214
          homeDirPath,
215 216 217 218 219 220 221
          '.local',
          'share',
          'Google',
          '$studioAppName$major.$minor',
        );
      }

222
      return globals.fs.path.join(
223
        homeDirPath,
224 225 226 227
        '.$studioAppName$major.$minor',
        'config',
        'plugins',
      );
228 229 230
    }
  }

231 232 233
  List<String> get validationMessages => _validationMessages;

  /// Locates the newest, valid version of Android Studio.
234 235 236 237
  ///
  /// In the case that `--android-studio-dir` is configured, the version of
  /// Android Studio found at that location is always returned, even if it is
  /// invalid.
238
  static AndroidStudio? latestValid() {
239
    final String? configuredStudioPath = globals.config.getValue('android-studio-dir') as String?;
240 241
    if (configuredStudioPath != null && !globals.fs.directory(configuredStudioPath).existsSync()) {
      throwToolExit('''
242 243 244 245 246 247
Could not find the Android Studio installation at the manually configured path "$configuredStudioPath".
Please verify that the path is correct and update it by running this command: flutter config --android-studio-dir '<path>'

To have flutter search for Android Studio installations automatically, remove
the configured path by running this command: flutter config --android-studio-dir ''
''');
248 249 250
    }

    // Find all available Studio installations.
251
    final List<AndroidStudio> studios = allInstalled();
252 253 254
    if (studios.isEmpty) {
      return null;
    }
255 256 257 258 259 260 261 262 263 264 265

    final AndroidStudio? manuallyConfigured = studios
      .where((AndroidStudio studio) => studio.configuredPath != null &&
        configuredStudioPath != null &&
        _pathsAreEqual(studio.configuredPath!, configuredStudioPath))
      .firstOrNull;

    if (manuallyConfigured != null) {
      return manuallyConfigured;
    }

266 267
    AndroidStudio? newest;
    for (final AndroidStudio studio in studios.where((AndroidStudio s) => s.isValid)) {
268 269 270 271 272 273 274 275 276 277 278 279 280
      if (newest == null) {
        newest = studio;
        continue;
      }

      // We prefer installs with known versions.
      if (studio.version != null && newest.version == null) {
        newest = studio;
      } else if (studio.version != null && newest.version != null &&
          studio.version! > newest.version!) {
        newest = studio;
      } else if (studio.version == null && newest.version == null &&
            studio.directory.compareTo(newest.directory) > 0) {
281 282 283 284 285
        newest = studio;
      }
    }

    return newest;
286 287 288
  }

  static List<AndroidStudio> allInstalled() =>
289
      globals.platform.isMacOS ? _allMacOS() : _allLinuxOrWindows();
290 291

  static List<AndroidStudio> _allMacOS() {
292
    final List<FileSystemEntity> candidatePaths = <FileSystemEntity>[];
293

294
    void checkForStudio(String path) {
295
      if (!globals.fs.isDirectorySync(path)) {
296
        return;
297
      }
298
      try {
299
        final Iterable<Directory> directories = globals.fs
300
            .directory(path)
301
            .listSync(followLinks: false)
302
            .whereType<Directory>();
303
        for (final Directory directory in directories) {
304 305 306
          final String name = directory.basename;
          // An exact match, or something like 'Android Studio 3.0 Preview.app'.
          if (name.startsWith('Android Studio') && name.endsWith('.app')) {
307 308
            candidatePaths.add(directory);
          } else if (!directory.path.endsWith('.app')) {
309
            checkForStudio(directory.path);
310
          }
311
        }
312
      } on Exception catch (e) {
313
        globals.printTrace('Exception while looking for Android Studio: $e');
314 315 316
      }
    }

317
    checkForStudio('/Applications');
318 319
    final String? homeDirPath = globals.fsUtils.homeDirPath;
    if (homeDirPath != null) {
320
      checkForStudio(globals.fs.path.join(
321 322 323 324
        homeDirPath,
        'Applications',
      ));
    }
325

326
    final String? configuredStudioDir = globals.config.getValue('android-studio-dir') as String?;
327
    FileSystemEntity? configuredStudioDirAsEntity;
328
    if (configuredStudioDir != null) {
329 330 331
      configuredStudioDirAsEntity = globals.fs.directory(configuredStudioDir);
      if (configuredStudioDirAsEntity.basename == 'Contents') {
        configuredStudioDirAsEntity = configuredStudioDirAsEntity.parent;
332 333
      }
      if (!candidatePaths
334 335
          .any((FileSystemEntity e) => _pathsAreEqual(e.path, configuredStudioDirAsEntity!.path))) {
        candidatePaths.add(configuredStudioDirAsEntity);
336 337 338
      }
    }

339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357
    // Query Spotlight for unexpected installation locations.
    String spotlightQueryResult = '';
    try {
      final ProcessResult spotlightResult = globals.processManager.runSync(<String>[
        'mdfind',
        // com.google.android.studio, com.google.android.studio-EAP
        'kMDItemCFBundleIdentifier="com.google.android.studio*"',
      ]);
      spotlightQueryResult = spotlightResult.stdout as String;
    } on ProcessException {
      // The Spotlight query is a nice-to-have, continue checking known installation locations.
    }
    for (final String studioPath in LineSplitter.split(spotlightQueryResult)) {
      final Directory appBundle = globals.fs.directory(studioPath);
      if (!candidatePaths.any((FileSystemEntity e) => e.path == studioPath)) {
        candidatePaths.add(appBundle);
      }
    }

358
    return candidatePaths
359 360 361 362 363 364 365 366 367 368 369 370
      .map<AndroidStudio?>((FileSystemEntity e) {
        if (configuredStudioDirAsEntity == null) {
          return AndroidStudio.fromMacOSBundle(e.path);
        }

        return AndroidStudio.fromMacOSBundle(
          e.path,
          configuredPath: _pathsAreEqual(configuredStudioDirAsEntity.path, e.path) ? configuredStudioDir : null,
        );
      })
      .whereType<AndroidStudio>()
      .toList();
371 372 373
  }

  static List<AndroidStudio> _allLinuxOrWindows() {
374
    final List<AndroidStudio> studios = <AndroidStudio>[];
375

376
    bool alreadyFoundStudioAt(String path, { Version? newerThan }) {
377
      return studios.any((AndroidStudio studio) {
378
        if (studio.directory != path) {
379
          return false;
380
        }
381
        if (newerThan != null) {
382 383 384 385
          if (studio.version == null) {
            return false;
          }
          return studio.version!.compareTo(newerThan) >= 0;
386 387 388 389 390
        }
        return true;
      });
    }

391 392 393 394
    // Read all $HOME/.AndroidStudio*/system/.home
    // or $HOME/.cache/Google/AndroidStudio*/.home files.
    // There may be several pointing to the same installation,
    // so we grab only the latest one.
395
    final String? homeDirPath = globals.fsUtils.homeDirPath;
396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419

    if (homeDirPath != null && globals.fs.directory(homeDirPath).existsSync()) {
      final Directory homeDir = globals.fs.directory(homeDirPath);

      final List<Directory> directoriesToSearch = <Directory>[homeDir];

      // >=4.1 has new install location at $HOME/.cache/Google
      final String cacheDirPath =
          globals.fs.path.join(homeDirPath, '.cache', 'Google');

      if (globals.fs.isDirectorySync(cacheDirPath)) {
        directoriesToSearch.add(globals.fs.directory(cacheDirPath));
      }

      final List<Directory> entities = <Directory>[];

      for (final Directory baseDir in directoriesToSearch) {
        final Iterable<Directory> directories =
            baseDir.listSync(followLinks: false).whereType<Directory>();
        entities.addAll(directories.where((Directory directory) =>
            _dotHomeStudioVersionMatcher.hasMatch(directory.basename)));
      }

      for (final Directory entity in entities) {
420
        final AndroidStudio? studio = AndroidStudio.fromHomeDot(entity);
421
        if (studio != null && !alreadyFoundStudioAt(studio.directory, newerThan: studio.version)) {
422 423
          studios.removeWhere((AndroidStudio other) => other.directory == studio.directory);
          studios.add(studio);
424 425 426
        }
      }
    }
427

428
    // Discover Android Studio > 4.1
429
    if (globals.platform.isWindows && globals.platform.environment.containsKey('LOCALAPPDATA')) {
430 431 432
      final Directory cacheDir = globals.fs.directory(globals.fs.path.join(globals.platform.environment['LOCALAPPDATA']!, 'Google'));
      if (!cacheDir.existsSync()) {
        return studios;
433
      }
434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451
      for (final Directory dir in cacheDir.listSync().whereType<Directory>()) {
        final String name  = globals.fs.path.basename(dir.path);
        AndroidStudioValidator.idToTitle.forEach((String id, String title) {
          if (name.startsWith(id)) {
            final String version = name.substring(id.length);
            String? installPath;

            try {
              installPath = globals.fs.file(globals.fs.path.join(dir.path, '.home')).readAsStringSync();
            } on FileSystemException {
              // ignored
            }
            if (installPath != null && globals.fs.isDirectorySync(installPath)) {
              final AndroidStudio studio = AndroidStudio(
                installPath,
                version: Version.parse(version),
                studioAppName: title,
              );
452
              if (!alreadyFoundStudioAt(studio.directory, newerThan: studio.version)) {
453
                studios.removeWhere((AndroidStudio other) => _pathsAreEqual(other.directory, studio.directory));
454 455 456
                studios.add(studio);
              }
            }
457
          }
458
        });
459 460 461
      }
    }

462
    final String? configuredStudioDir = globals.config.getValue('android-studio-dir') as String?;
463 464 465 466 467 468 469 470 471 472 473 474 475 476 477
    if (configuredStudioDir != null) {
      final AndroidStudio? matchingAlreadyFoundInstall = studios
        .where((AndroidStudio other) => _pathsAreEqual(configuredStudioDir, other.directory))
        .firstOrNull;
      if (matchingAlreadyFoundInstall != null) {
        studios.remove(matchingAlreadyFoundInstall);
        studios.add(
          AndroidStudio(
            configuredStudioDir,
            configuredPath: configuredStudioDir,
            version: matchingAlreadyFoundInstall.version,
          ),
        );
      } else {
        studios.add(AndroidStudio(configuredStudioDir,
478
          configuredPath: configuredStudioDir));
479
      }
480 481
    }

482
    if (globals.platform.isLinux) {
483
      void checkWellKnownPath(String path) {
484
        if (globals.fs.isDirectorySync(path) && !alreadyFoundStudioAt(path)) {
485
          studios.add(AndroidStudio(path));
486 487 488 489
        }
      }

      // Add /opt/android-studio and $HOME/android-studio, if they exist.
490 491
      checkWellKnownPath('/opt/android-studio');
      checkWellKnownPath('${globals.fsUtils.homeDirPath}/android-studio');
492 493 494 495
    }
    return studios;
  }

496 497
  static String? extractStudioPlistValueWithMatcher(String plistValue, RegExp keyMatcher) {
    return keyMatcher.stringMatch(plistValue)?.split('=').last.trim().replaceAll('"', '');
498 499
  }

500
  void _initAndValidate() {
501 502 503
    _isValid = false;
    _validationMessages.clear();

504 505
    if (configuredPath != null) {
      _validationMessages.add('android-studio-dir = $configuredPath');
506 507
    }

508
    if (!globals.fs.isDirectorySync(directory)) {
509 510 511 512
      _validationMessages.add('Android Studio not found at $directory');
      return;
    }

513 514
    final String javaPath;
    if (globals.platform.isMacOS) {
515
      if (version != null && version!.major < 2020) {
516
        javaPath = globals.fs.path.join(directory, 'jre', 'jdk', 'Contents', 'Home');
517
      } else if (version != null && version!.major < 2022) {
518
        javaPath = globals.fs.path.join(directory, 'jre', 'Contents', 'Home');
519 520 521
      // See https://github.com/flutter/flutter/issues/125246 for more context.
      } else {
        javaPath = globals.fs.path.join(directory, 'jbr', 'Contents', 'Home');
522 523
      }
    } else {
524
      if (version != null && version!.major < 2022) {
525
        javaPath = globals.fs.path.join(directory, 'jre');
526 527
      } else {
        javaPath = globals.fs.path.join(directory, 'jbr');
528 529
      }
    }
530 531
    final String javaExecutable = globals.fs.path.join(javaPath, 'bin', 'java');
    if (!globals.processManager.canRun(javaExecutable)) {
532
      _validationMessages.add('Unable to find bundled Java version.');
533
    } else {
534
      RunResult? result;
535
      try {
536
        result = globals.processUtils.runSync(<String>[javaExecutable, '-version']);
537 538 539 540
      } on ProcessException catch (e) {
        _validationMessages.add('Failed to run Java: $e');
      }
      if (result != null && result.exitCode == 0) {
541 542
        final List<String> versionLines = result.stderr.split('\n');
        final String javaVersion = versionLines.length >= 2 ? versionLines[1] : versionLines[0];
543
        _validationMessages.add('Java version $javaVersion');
544
        _javaPath = javaPath;
545
        _isValid = true;
546 547 548
      } else {
        _validationMessages.add('Unable to determine bundled Java version.');
      }
549
    }
550 551 552
  }

  @override
553
  String toString() => 'Android Studio ($version)';
554
}
555 556 557 558

bool _pathsAreEqual(String path, String other) {
  return globals.fs.path.canonicalize(path) == globals.fs.path.canonicalize(other);
}