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

import '../base/file_system.dart';
6
import '../base/io.dart';
7
import '../base/process.dart';
8
import '../base/utils.dart';
9
import '../base/version.dart';
10
import '../convert.dart';
11
import '../globals.dart' as globals;
12
import '../ios/plist_parser.dart';
13
import 'android_studio_validator.dart';
14 15 16 17 18

// Android Studio layout:

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

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

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

30
String? get javaPath => globals.androidStudio?.javaPath;
31

32
class AndroidStudio implements Comparable<AndroidStudio> {
33 34
  AndroidStudio(
    this.directory, {
35
    Version? version,
36 37 38 39
    this.configured,
    this.studioAppName = 'AndroidStudio',
    this.presetPluginsPath,
  }) : version = version ?? Version.unknown {
40
    _init(version: version);
41 42
  }

43 44 45 46 47 48 49
  static AndroidStudio? fromMacOSBundle(String bundlePath) {
    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;
50 51
    }

52
    final String? versionString = plistValues[PlistParser.kCFBundleShortVersionStringKey] as String?;
53

54
    Version? version;
55
    if (versionString != null) {
56
      version = Version.parse(versionString);
57
    }
58

59 60
    String? pathsSelectorValue;
    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 69 70 71 72
    final int? major = version?.major;
    final int? minor = version?.minor;
    String? presetPluginsPath;
    final String? homeDirPath = globals.fsUtils.homeDirPath;
    if (homeDirPath != null && pathsSelectorValue != null) {
73 74
      if (major != null && major >= 4 && minor != null && minor >= 1) {
        presetPluginsPath = globals.fs.path.join(
75
          homeDirPath,
76 77 78 79 80 81 82
          'Library',
          'Application Support',
          'Google',
          pathsSelectorValue,
        );
      } else {
        presetPluginsPath = globals.fs.path.join(
83
          homeDirPath,
84 85 86 87 88 89
          'Library',
          'Application Support',
          pathsSelectorValue,
        );
      }
    }
90
    return AndroidStudio(studioPath, version: version, presetPluginsPath: presetPluginsPath);
91 92
  }

93 94
  static AndroidStudio? fromHomeDot(Directory homeDotDir) {
    final Match? versionMatch =
95 96 97 98
        _dotHomeStudioVersionMatcher.firstMatch(homeDotDir.basename);
    if (versionMatch?.groupCount != 2) {
      return null;
    }
99 100
    final Version? version = Version.parse(versionMatch![2]);
    final String? studioAppName = versionMatch[1];
101
    if (studioAppName == null || version == null) {
102 103
      return null;
    }
104

105 106
    final int major = version.major;
    final int minor = version.minor;
107 108 109 110 111 112

    // 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;

113
    if (major >= 4 && minor >= 1) {
114 115 116 117 118 119
      dotHomeFilePath = globals.fs.path.join(homeDotDir.path, '.home');
    } else {
      dotHomeFilePath =
          globals.fs.path.join(homeDotDir.path, 'system', '.home');
    }

120
    String? installPath;
121

122
    try {
123
      installPath = globals.fs.file(dotHomeFilePath).readAsStringSync();
124
    } on Exception {
125
      // ignored, installPath will be null, which is handled below
126
    }
127

128
    if (installPath != null && globals.fs.isDirectorySync(installPath)) {
129 130 131 132 133
      return AndroidStudio(
          installPath,
          version: version,
          studioAppName: studioAppName,
      );
134 135 136 137
    }
    return null;
  }

138 139 140
  final String directory;
  final String studioAppName;
  final Version version;
141 142
  final String? configured;
  final String? presetPluginsPath;
143

144
  String? _javaPath;
145 146 147
  bool _isValid = false;
  final List<String> _validationMessages = <String>[];

148
  String? get javaPath => _javaPath;
149

150 151
  bool get isValid => _isValid;

152
  String? get pluginsPath {
153
    if (presetPluginsPath != null) {
154 155 156 157 158 159 160
      return presetPluginsPath!;
    }
    final int major = version.major;
    final int minor = version.minor;
    final String? homeDirPath = globals.fsUtils.homeDirPath;
    if (homeDirPath == null) {
      return null;
161
    }
162
    if (globals.platform.isMacOS) {
163
      /// plugin path of Android Studio has been changed after version 4.1.
164
      if (major >= 4 && minor >= 1) {
165
        return globals.fs.path.join(
166
          homeDirPath,
167 168 169 170 171 172 173
          'Library',
          'Application Support',
          'Google',
          'AndroidStudio$major.$minor',
        );
      } else {
        return globals.fs.path.join(
174
          homeDirPath,
175 176 177 178 179
          'Library',
          'Application Support',
          'AndroidStudio$major.$minor',
        );
      }
180
    } else {
181 182 183 184 185 186 187
      // JetBrains Toolbox write plugins here
      final String toolboxPluginsPath = '$directory.plugins';

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

188
      if (major >= 4 && minor >= 1 &&
189 190
          globals.platform.isLinux) {
        return globals.fs.path.join(
191
          homeDirPath,
192 193 194 195 196 197 198
          '.local',
          'share',
          'Google',
          '$studioAppName$major.$minor',
        );
      }

199
      return globals.fs.path.join(
200
        homeDirPath,
201 202 203 204
        '.$studioAppName$major.$minor',
        'config',
        'plugins',
      );
205 206 207
    }
  }

208 209 210 211
  List<String> get validationMessages => _validationMessages;

  @override
  int compareTo(AndroidStudio other) {
212
    final int result = version.compareTo(other.version);
213
    if (result == 0) {
214
      return directory.compareTo(other.directory);
215
    }
216 217 218 219
    return result;
  }

  /// Locates the newest, valid version of Android Studio.
220
  static AndroidStudio? latestValid() {
221
    final String? configuredStudio = globals.config.getValue('android-studio-dir') as String?;
222 223
    if (configuredStudio != null) {
      String configuredStudioPath = configuredStudio;
224 225
      if (globals.platform.isMacOS && !configuredStudioPath.endsWith('Contents')) {
        configuredStudioPath = globals.fs.path.join(configuredStudioPath, 'Contents');
226
      }
227
      return AndroidStudio(configuredStudioPath,
228 229 230 231
          configured: configuredStudio);
    }

    // Find all available Studio installations.
232
    final List<AndroidStudio> studios = allInstalled();
233 234 235
    if (studios.isEmpty) {
      return null;
    }
236 237 238 239 240 241 242 243
    AndroidStudio? newest;
    for (final AndroidStudio studio in studios.where((AndroidStudio s) => s.isValid)) {
      if (newest == null || studio.compareTo(newest) > 0) {
        newest = studio;
      }
    }

    return newest;
244 245 246
  }

  static List<AndroidStudio> allInstalled() =>
247
      globals.platform.isMacOS ? _allMacOS() : _allLinuxOrWindows();
248 249

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

252
    void checkForStudio(String path) {
253
      if (!globals.fs.isDirectorySync(path)) {
254
        return;
255
      }
256
      try {
257
        final Iterable<Directory> directories = globals.fs
258
            .directory(path)
259
            .listSync(followLinks: false)
260
            .whereType<Directory>();
261
        for (final Directory directory in directories) {
262 263 264
          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')) {
265 266
            candidatePaths.add(directory);
          } else if (!directory.path.endsWith('.app')) {
267
            checkForStudio(directory.path);
268
          }
269
        }
270
      } on Exception catch (e) {
271
        globals.printTrace('Exception while looking for Android Studio: $e');
272 273 274
      }
    }

275
    checkForStudio('/Applications');
276 277
    final String? homeDirPath = globals.fsUtils.homeDirPath;
    if (homeDirPath != null) {
278
      checkForStudio(globals.fs.path.join(
279 280 281 282
        homeDirPath,
        'Applications',
      ));
    }
283

284
    final String? configuredStudioDir = globals.config.getValue('android-studio-dir') as String?;
285
    if (configuredStudioDir != null) {
286
      FileSystemEntity configuredStudio = globals.fs.file(configuredStudioDir);
287 288 289 290 291 292 293 294 295
      if (configuredStudio.basename == 'Contents') {
        configuredStudio = configuredStudio.parent;
      }
      if (!candidatePaths
          .any((FileSystemEntity e) => e.path == configuredStudio.path)) {
        candidatePaths.add(configuredStudio);
      }
    }

296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314
    // 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);
      }
    }

315
    return candidatePaths
316
        .map<AndroidStudio?>((FileSystemEntity e) => AndroidStudio.fromMacOSBundle(e.path))
317
        .whereType<AndroidStudio>()
318 319 320 321
        .toList();
  }

  static List<AndroidStudio> _allLinuxOrWindows() {
322
    final List<AndroidStudio> studios = <AndroidStudio>[];
323

324
    bool hasStudioAt(String path, { Version? newerThan }) {
325
      return studios.any((AndroidStudio studio) {
326
        if (studio.directory != path) {
327
          return false;
328
        }
329 330 331 332 333 334 335
        if (newerThan != null) {
          return studio.version.compareTo(newerThan) >= 0;
        }
        return true;
      });
    }

336 337 338 339
    // 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.
340
    final String? homeDirPath = globals.fsUtils.homeDirPath;
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364

    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) {
365
        final AndroidStudio? studio = AndroidStudio.fromHomeDot(entity);
366
        if (studio != null && !hasStudioAt(studio.directory, newerThan: studio.version)) {
367 368
          studios.removeWhere((AndroidStudio other) => other.directory == studio.directory);
          studios.add(studio);
369 370 371
        }
      }
    }
372

373
    // Discover Android Studio > 4.1
374
    if (globals.platform.isWindows && globals.platform.environment.containsKey('LOCALAPPDATA')) {
375 376 377
      final Directory cacheDir = globals.fs.directory(globals.fs.path.join(globals.platform.environment['LOCALAPPDATA']!, 'Google'));
      if (!cacheDir.existsSync()) {
        return studios;
378
      }
379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396
      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,
              );
397
              if (!hasStudioAt(studio.directory, newerThan: studio.version)) {
398 399 400 401
                studios.removeWhere((AndroidStudio other) => other.directory == studio.directory);
                studios.add(studio);
              }
            }
402
          }
403
        });
404 405 406
      }
    }

407
    final String? configuredStudioDir = globals.config.getValue('android-studio-dir') as String?;
408
    if (configuredStudioDir != null && !hasStudioAt(configuredStudioDir)) {
409
      studios.add(AndroidStudio(configuredStudioDir,
410 411 412
          configured: configuredStudioDir));
    }

413
    if (globals.platform.isLinux) {
414 415
      void checkWellKnownPath(String path) {
        if (globals.fs.isDirectorySync(path) && !hasStudioAt(path)) {
416
          studios.add(AndroidStudio(path));
417 418 419 420
        }
      }

      // Add /opt/android-studio and $HOME/android-studio, if they exist.
421 422
      checkWellKnownPath('/opt/android-studio');
      checkWellKnownPath('${globals.fsUtils.homeDirPath}/android-studio');
423 424 425 426
    }
    return studios;
  }

427 428
  static String? extractStudioPlistValueWithMatcher(String plistValue, RegExp keyMatcher) {
    return keyMatcher.stringMatch(plistValue)?.split('=').last.trim().replaceAll('"', '');
429 430
  }

431
  void _init({Version? version}) {
432 433 434 435 436 437 438
    _isValid = false;
    _validationMessages.clear();

    if (configured != null) {
      _validationMessages.add('android-studio-dir = $configured');
    }

439
    if (!globals.fs.isDirectorySync(directory)) {
440 441 442 443
      _validationMessages.add('Android Studio not found at $directory');
      return;
    }

444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459
    final String javaPath;
    if (globals.platform.isMacOS) {
      if (version != null && version.major < 2020) {
        javaPath = globals.fs.path.join(directory, 'jre', 'jdk', 'Contents', 'Home');
      } else if (version != null && version.major == 2022) {
        javaPath = globals.fs.path.join(directory, 'jbr', 'Contents', 'Home');
      } else {
        javaPath = globals.fs.path.join(directory, 'jre', 'Contents', 'Home');
      }
    } else {
      if (version != null && version.major == 2022) {
        javaPath = globals.fs.path.join(directory, 'jbr');
      } else {
        javaPath = globals.fs.path.join(directory, 'jre');
      }
    }
460 461
    final String javaExecutable = globals.fs.path.join(javaPath, 'bin', 'java');
    if (!globals.processManager.canRun(javaExecutable)) {
462
      _validationMessages.add('Unable to find bundled Java version.');
463
    } else {
464
      RunResult? result;
465
      try {
466
        result = globals.processUtils.runSync(<String>[javaExecutable, '-version']);
467 468 469 470
      } on ProcessException catch (e) {
        _validationMessages.add('Failed to run Java: $e');
      }
      if (result != null && result.exitCode == 0) {
471 472
        final List<String> versionLines = result.stderr.split('\n');
        final String javaVersion = versionLines.length >= 2 ? versionLines[1] : versionLines[0];
473
        _validationMessages.add('Java version $javaVersion');
474
        _javaPath = javaPath;
475
        _isValid = true;
476 477 478
      } else {
        _validationMessages.add('Unable to determine bundled Java version.');
      }
479
    }
480 481 482
  }

  @override
483
  String toString() => 'Android Studio ($version)';
484
}