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

import 'dart:async';

7
import 'package:meta/meta.dart';
8
import 'package:yaml/yaml.dart' as yaml;
9

10
import '../android/android.dart' as android;
11
import '../android/android_sdk.dart' as android_sdk;
12
import '../android/gradle_utils.dart' as gradle;
13
import '../base/common.dart';
14
import '../base/context.dart';
15
import '../base/file_system.dart';
16
import '../base/io.dart';
17
import '../base/net.dart';
18
import '../base/os.dart';
19
import '../base/utils.dart';
20
import '../cache.dart';
21
import '../convert.dart';
22
import '../dart/pub.dart';
23
import '../doctor.dart';
24
import '../features.dart';
25
import '../globals.dart' as globals;
26
import '../project.dart';
27
import '../reporting/reporting.dart';
28
import '../runner/flutter_command.dart';
29
import '../template.dart';
Hixie's avatar
Hixie committed
30

31
enum _ProjectType {
32 33 34
  /// This is the default project with the user-managed host code.
  /// It is different than the "module" template in that it exposes and doesn't
  /// manage the platform code.
35
  app,
36
  /// The is a project that has managed platform host code. It is an application with
37
  /// ephemeral .ios and .android directories that can be updated automatically.
38
  module,
39 40
  /// This is a Flutter Dart package project. It doesn't have any native
  /// components, only Dart.
41
  package,
42
  /// This is a native plugin project.
43 44 45
  plugin,
}

46 47
_ProjectType _stringToProjectType(String value) {
  _ProjectType result;
48
  for (final _ProjectType type in _ProjectType.values) {
49 50 51 52 53 54 55 56
    if (value == getEnumName(type)) {
      result = type;
      break;
    }
  }
  return result;
}

57
class CreateCommand extends FlutterCommand {
58
  CreateCommand() {
59 60
    argParser.addFlag('pub',
      defaultsTo: true,
61
      help: 'Whether to run "flutter pub get" after the project has been created.',
62
    );
63 64
    argParser.addFlag('offline',
      defaultsTo: false,
65
      help: 'When "flutter pub get" is run by the create command, this indicates '
66
        'whether to run it in offline mode or not. In offline mode, it will need to '
67
        'have all dependencies already available in the pub cache to succeed.',
68
    );
69 70 71 72
    argParser.addFlag(
      'with-driver-test',
      negatable: true,
      defaultsTo: false,
73
      help: "Also add a flutter_driver dependency and generate a sample 'flutter drive' test.",
74
    );
75 76 77
    argParser.addOption(
      'template',
      abbr: 't',
78
      allowed: _ProjectType.values.map<String>((_ProjectType type) => getEnumName(type)),
79 80 81
      help: 'Specify the type of project to create.',
      valueHelp: 'type',
      allowedHelp: <String, String>{
82
        getEnumName(_ProjectType.app): '(default) Generate a Flutter application.',
83
        getEnumName(_ProjectType.package): 'Generate a shareable Flutter project containing modular '
84
            'Dart code.',
85
        getEnumName(_ProjectType.plugin): 'Generate a shareable Flutter project containing an API '
86 87
            'in Dart code with a platform-specific implementation for Android, for iOS code, or '
            'for both.',
88 89
        getEnumName(_ProjectType.module): 'Generate a project to add a Flutter module to an '
            'existing Android or iOS application.',
90
      },
91
      defaultsTo: null,
92
    );
93 94 95 96
    argParser.addOption(
      'sample',
      abbr: 's',
      help: 'Specifies the Flutter code sample to use as the main.dart for an application. Implies '
97
        '--template=app. The value should be the sample ID of the desired sample from the API '
98 99
        'documentation website (http://docs.flutter.dev). An example can be found at '
        'https://master-api.flutter.dev/flutter/widgets/SingleChildScrollView-class.html',
100
      defaultsTo: null,
101
      valueHelp: 'id',
102
    );
103 104 105 106 107 108
    argParser.addOption(
      'list-samples',
      help: 'Specifies a JSON output file for a listing of Flutter code samples '
        'that can created with --sample.',
      valueHelp: 'path',
    );
109 110 111 112 113 114
    argParser.addFlag(
      'overwrite',
      negatable: true,
      defaultsTo: false,
      help: 'When performing operations, overwrite existing files.',
    );
115 116
    argParser.addOption(
      'description',
117
      defaultsTo: 'A new Flutter project.',
118
      help: 'The description to use for your new Flutter project. This string ends up in the pubspec.yaml file.',
119 120 121
    );
    argParser.addOption(
      'org',
122
      defaultsTo: 'com.example',
123
      help: 'The organization responsible for your new Flutter project, in reverse domain name notation. '
124
            'This string is used in Java package names and as prefix in the iOS bundle identifier.',
125
    );
126 127 128
    argParser.addOption(
      'project-name',
      defaultsTo: null,
129
      help: 'The project name for this new Flutter project. This must be a valid dart package name.',
130
    );
131 132 133
    argParser.addOption(
      'ios-language',
      abbr: 'i',
134
      defaultsTo: 'swift',
135 136 137 138 139
      allowed: <String>['objc', 'swift'],
    );
    argParser.addOption(
      'android-language',
      abbr: 'a',
140
      defaultsTo: 'kotlin',
141 142
      allowed: <String>['java', 'kotlin'],
    );
143 144 145
    argParser.addFlag(
      'androidx',
      negatable: true,
146
      defaultsTo: true,
147 148
      help: 'Generate a project using the AndroidX support libraries',
    );
149 150
  }

151 152 153 154 155 156 157
  @override
  final String name = 'create';

  @override
  final String description = 'Create a new Flutter project.\n\n'
    'If run on a project that already exists, this will repair the project, recreating any files that are missing.';

158
  @override
159
  String get invocation => '${runner.executableName} $name <output directory>';
160

161
  @override
162 163
  Future<Map<CustomDimensions, String>> get usageValues async {
    return <CustomDimensions, String>{
164 165 166
      CustomDimensions.commandCreateProjectType: stringArg('template'),
      CustomDimensions.commandCreateAndroidLanguage: stringArg('android-language'),
      CustomDimensions.commandCreateIosLanguage: stringArg('ios-language'),
167 168 169
    };
  }

170 171 172 173 174 175 176 177
  // Lazy-initialize the net utilities with values from the context.
  Net _cachedNet;
  Net get _net => _cachedNet ??= Net(
    httpClientFactory: context.get<HttpClientFactory>() ?? () => HttpClient(),
    logger: globals.logger,
    platform: globals.platform,
  );

178 179
  // If it has a .metadata file with the project_type in it, use that.
  // If it has an android dir and an android/app dir, it's a legacy app
180 181 182 183
  // If it has an ios dir and an ios/Flutter dir, it's a legacy app
  // Otherwise, we don't presume to know what type of project it could be, since
  // many of the files could be missing, and we can't really tell definitively.
  _ProjectType _determineTemplateType(Directory projectDir) {
184
    yaml.YamlMap loadMetadata(Directory projectDir) {
185
      if (!projectDir.existsSync()) {
186
        return null;
187
      }
188
      final File metadataFile = globals.fs.file(globals.fs.path.join(projectDir.absolute.path, '.metadata'));
189
      if (!metadataFile.existsSync()) {
190
        return null;
191
      }
192 193 194 195 196 197 198
      final dynamic metadataYaml = yaml.loadYaml(metadataFile.readAsStringSync());
      if (metadataYaml is yaml.YamlMap) {
        return metadataYaml;
      } else {
        throwToolExit('pubspec.yaml is malformed.');
        return null;
      }
199 200
    }

201
    bool exists(List<String> path) {
202
      return globals.fs.directory(globals.fs.path.joinAll(<String>[projectDir.absolute.path, ...path])).existsSync();
203 204
    }

205 206 207
    // If it exists, the project type in the metadata is definitive.
    final yaml.YamlMap metadata = loadMetadata(projectDir);
    if (metadata != null && metadata['project_type'] != null) {
208 209 210 211 212 213 214
      final dynamic projectType = metadata['project_type'];
      if (projectType is String) {
        return _stringToProjectType(projectType);
      } else {
        throwToolExit('.metadata is malformed.');
        return null;
      }
215 216 217 218 219
    }

    // There either wasn't any metadata, or it didn't contain the project type,
    // so try and figure out what type of project it is from the existing
    // directory structure.
220 221 222 223 224 225 226
    if (exists(<String>['android', 'app'])
        || exists(<String>['ios', 'Runner'])
        || exists(<String>['ios', 'Flutter'])) {
      return _ProjectType.app;
    }
    // Since we can't really be definitive on nearly-empty directories, err on
    // the side of prudence and just say we don't know.
227 228 229
    return null;
  }

230
  /// The hostname for the Flutter docs for the current channel.
231
  String get _snippetsHost => globals.flutterVersion.channel == 'stable'
232 233 234
        ? 'docs.flutter.io'
        : 'master-docs.flutter.io';

235 236 237 238 239 240 241
  Future<String> _fetchSampleFromServer(String sampleId) async {
    // Sanity check the sampleId
    if (sampleId.contains(RegExp(r'[^-\w\.]'))) {
      throwToolExit('Sample ID "$sampleId" contains invalid characters. Check the ID in the '
        'documentation and try again.');
    }

242 243
    final Uri snippetsUri = Uri.https(_snippetsHost, 'snippets/$sampleId.dart');
    return utf8.decode(await _net.fetchUrl(snippetsUri));
244 245 246 247
  }

  /// Fetches the samples index file from the Flutter docs website.
  Future<String> _fetchSamplesIndexFromServer() async {
248 249
    final Uri snippetsUri = Uri.https(_snippetsHost, 'snippets/index.json');
    return utf8.decode(await _net.fetchUrl(snippetsUri, maxAttempts: 2));
250 251 252 253 254 255
  }

  /// Fetches the samples index file from the server and writes it to
  /// [outputFilePath].
  Future<void> _writeSamplesJson(String outputFilePath) async {
    try {
256
      final File outputFile = globals.fs.file(outputFilePath);
257 258 259
      if (outputFile.existsSync()) {
        throwToolExit('File "$outputFilePath" already exists', exitCode: 1);
      }
260 261 262 263 264 265
      final String samplesJson = await _fetchSamplesIndexFromServer();
      if (samplesJson == null) {
        throwToolExit('Unable to download samples', exitCode: 2);
      }
      else {
        outputFile.writeAsStringSync(samplesJson);
266
        globals.printStatus('Wrote samples JSON to "$outputFilePath"');
267
      }
268 269 270
    } catch (e) {
      throwToolExit('Failed to write samples JSON to "$outputFilePath": $e', exitCode: 2);
    }
271 272
  }

273 274 275 276 277
  _ProjectType _getProjectType(Directory projectDir) {
    _ProjectType template;
    _ProjectType detectedProjectType;
    final bool metadataExists = projectDir.absolute.childFile('.metadata').existsSync();
    if (argResults['template'] != null) {
278
      template = _stringToProjectType(stringArg('template'));
279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
    } else {
      // If the project directory exists and isn't empty, then try to determine the template
      // type from the project directory.
      if (projectDir.existsSync() && projectDir.listSync().isNotEmpty) {
        detectedProjectType = _determineTemplateType(projectDir);
        if (detectedProjectType == null && metadataExists) {
          // We can only be definitive that this is the wrong type if the .metadata file
          // exists and contains a type that we don't understand, or doesn't contain a type.
          throwToolExit('Sorry, unable to detect the type of project to recreate. '
              'Try creating a fresh project and migrating your existing code to '
              'the new project manually.');
        }
      }
    }
    template ??= detectedProjectType ?? _ProjectType.app;
    if (detectedProjectType != null && template != detectedProjectType && metadataExists) {
      // We can only be definitive that this is the wrong type if the .metadata file
      // exists and contains a type that doesn't match.
      throwToolExit("The requested template type '${getEnumName(template)}' doesn't match the "
          "existing template type of '${getEnumName(detectedProjectType)}'.");
    }
    return template;
  }

303
  @override
304
  Future<FlutterCommandResult> runCommand() async {
305
    if (argResults['list-samples'] != null) {
306 307 308
      // _writeSamplesJson can potentially be long-lived.
      Cache.releaseLockEarly();

309
      await _writeSamplesJson(stringArg('list-samples'));
310
      return FlutterCommandResult.success();
311 312
    }

313
    if (argResults.rest.isEmpty) {
314
      throwToolExit('No option specified for the output directory.\n$usage', exitCode: 2);
315
    }
316

317
    if (argResults.rest.length > 1) {
318
      String message = 'Multiple output directories specified.';
319
      for (final String arg in argResults.rest) {
320
        if (arg.startsWith('-')) {
321
          message += '\nTry moving $arg to be immediately following $name';
322 323 324
          break;
        }
      }
325
      throwToolExit(message, exitCode: 2);
326 327
    }

328
    if (Cache.flutterRoot == null) {
329
      throwToolExit('Neither the --flutter-root command line flag nor the FLUTTER_ROOT environment '
330
        'variable was specified. Unable to find package:flutter.', exitCode: 2);
331
    }
332

333
    final String flutterRoot = globals.fs.path.absolute(Cache.flutterRoot);
334

335 336 337
    final String flutterPackagesDirectory = globals.fs.path.join(flutterRoot, 'packages');
    final String flutterPackagePath = globals.fs.path.join(flutterPackagesDirectory, 'flutter');
    if (!globals.fs.isFileSync(globals.fs.path.join(flutterPackagePath, 'pubspec.yaml'))) {
338
      throwToolExit('Unable to find package:flutter in $flutterPackagePath', exitCode: 2);
339
    }
340

341 342
    final String flutterDriverPackagePath = globals.fs.path.join(flutterRoot, 'packages', 'flutter_driver');
    if (!globals.fs.isFileSync(globals.fs.path.join(flutterDriverPackagePath, 'pubspec.yaml'))) {
343
      throwToolExit('Unable to find package:flutter_driver in $flutterDriverPackagePath', exitCode: 2);
344
    }
345

346 347
    final Directory projectDir = globals.fs.directory(argResults.rest.first);
    final String projectDirPath = globals.fs.path.normalize(projectDir.absolute.path);
348

349 350 351
    String sampleCode;
    if (argResults['sample'] != null) {
      if (argResults['template'] != null &&
352
        _stringToProjectType(stringArg('template') ?? 'app') != _ProjectType.app) {
353 354 355 356
        throwToolExit('Cannot specify --sample with a project type other than '
          '"${getEnumName(_ProjectType.app)}"');
      }
      // Fetch the sample from the server.
357
      sampleCode = await _fetchSampleFromServer(stringArg('sample'));
358 359
    }

360
    final _ProjectType template = _getProjectType(projectDir);
361
    final bool generateModule = template == _ProjectType.module;
362 363
    final bool generatePlugin = template == _ProjectType.plugin;
    final bool generatePackage = template == _ProjectType.package;
364

365
    String organization = stringArg('org');
366
    if (!argResults.wasParsed('org')) {
367
      final FlutterProject project = FlutterProject.fromDirectory(projectDir);
368
      final Set<String> existingOrganizations = await project.organizationNames;
369 370
      if (existingOrganizations.length == 1) {
        organization = existingOrganizations.first;
371
      } else if (existingOrganizations.length > 1) {
372
        throwToolExit(
373
          'Ambiguous organization in existing files: $existingOrganizations. '
374 375 376 377
          'The --org command line argument must be specified to recreate project.'
        );
      }
    }
378

379 380
    final bool overwrite = boolArg('overwrite');
    String error = _validateProjectDir(projectDirPath, flutterRoot: flutterRoot, overwrite: overwrite);
381
    if (error != null) {
382
      throwToolExit(error);
383
    }
Devon Carew's avatar
Devon Carew committed
384

385
    final String projectName = stringArg('project-name') ?? globals.fs.path.basename(projectDirPath);
386
    error = _validateProjectName(projectName);
387
    if (error != null) {
388
      throwToolExit(error);
389
    }
390

391
    final Map<String, dynamic> templateContext = _templateContext(
392
      organization: organization,
393
      projectName: projectName,
394
      projectDescription: stringArg('description'),
395
      flutterRoot: flutterRoot,
396
      renderDriverTest: boolArg('with-driver-test'),
397
      withPluginHook: generatePlugin,
398 399 400
      androidX: boolArg('androidx'),
      androidLanguage: stringArg('android-language'),
      iosLanguage: stringArg('ios-language'),
401
      web: featureFlags.isWebEnabled,
402
      macos: featureFlags.isMacOSEnabled,
403
    );
404

405
    final String relativeDirPath = globals.fs.path.relative(projectDirPath);
406
    if (!projectDir.existsSync() || projectDir.listSync().isEmpty) {
407
      globals.printStatus('Creating project $relativeDirPath... androidx: ${boolArg('androidx')}');
408
    } else {
409
      if (sampleCode != null && !overwrite) {
410 411 412
        throwToolExit('Will not overwrite existing project in $relativeDirPath: '
          'must specify --overwrite for samples to overwrite.');
      }
413
      globals.printStatus('Recreating project $relativeDirPath...');
414
    }
415

416
    final Directory relativeDir = globals.fs.directory(projectDirPath);
417 418
    int generatedFileCount = 0;
    switch (template) {
419
      case _ProjectType.app:
420
        generatedFileCount += await _generateApp(relativeDir, templateContext, overwrite: overwrite);
421
        break;
422
      case _ProjectType.module:
423
        generatedFileCount += await _generateModule(relativeDir, templateContext, overwrite: overwrite);
424
        break;
425
      case _ProjectType.package:
426
        generatedFileCount += await _generatePackage(relativeDir, templateContext, overwrite: overwrite);
427
        break;
428
      case _ProjectType.plugin:
429
        generatedFileCount += await _generatePlugin(relativeDir, templateContext, overwrite: overwrite);
430 431
        break;
    }
432
    if (sampleCode != null) {
433
      generatedFileCount += _applySample(relativeDir, sampleCode);
434
    }
435 436
    globals.printStatus('Wrote $generatedFileCount files.');
    globals.printStatus('\nAll done!');
437
    final String application = sampleCode != null ? 'sample application' : 'application';
438
    if (generatePackage) {
439
      final String relativeMainPath = globals.fs.path.normalize(globals.fs.path.join(
440 441 442 443
        relativeDirPath,
        'lib',
        '${templateContext['projectName']}.dart',
      ));
444
      globals.printStatus('Your package code is in $relativeMainPath');
445
    } else if (generateModule) {
446
      final String relativeMainPath = globals.fs.path.normalize(globals.fs.path.join(
447 448 449 450
          relativeDirPath,
          'lib',
          'main.dart',
      ));
451
      globals.printStatus('Your module code is in $relativeMainPath.');
452 453
    } else {
      // Run doctor; tell the user the next steps.
454
      final FlutterProject project = FlutterProject.fromPath(projectDirPath);
455
      final FlutterProject app = project.hasExampleApp ? project.example : project;
456 457 458 459
      final String relativeAppPath = globals.fs.path.normalize(globals.fs.path.relative(app.directory.path));
      final String relativeAppMain = globals.fs.path.join(relativeAppPath, 'lib', 'main.dart');
      final String relativePluginPath = globals.fs.path.normalize(globals.fs.path.relative(projectDirPath));
      final String relativePluginMain = globals.fs.path.join(relativePluginPath, 'lib', '$projectName.dart');
460 461 462 463
      if (doctor.canLaunchAnything) {
        // Let them know a summary of the state of their tooling.
        await doctor.summary();

464
        globals.printStatus('''
465
In order to run your $application, type:
466 467 468 469

  \$ cd $relativeAppPath
  \$ flutter run

470
Your $application code is in $relativeAppMain.
471 472
''');
        if (generatePlugin) {
473
          globals.printStatus('''
474
Your plugin code is in $relativePluginMain.
475

476
Host platform code is in the "android" and "ios" directories under $relativePluginPath.
477
To edit platform code in an IDE see https://flutter.dev/developing-packages/#edit-plugin-package.
478 479 480
''');
        }
      } else {
481
        globals.printStatus("You'll need to install additional components before you can run "
482
            'your Flutter app:');
483
        globals.printStatus('');
484 485 486

        // Give the user more detailed analysis.
        await doctor.diagnose();
487 488
        globals.printStatus('');
        globals.printStatus("After installing components, run 'flutter doctor' in order to "
489
            're-validate your setup.');
490
        globals.printStatus("When complete, type 'flutter run' from the '$relativeAppPath' "
491
            'directory in order to launch your app.');
492
        globals.printStatus('Your $application code is in $relativeAppMain');
493
      }
494
    }
495
    return FlutterCommandResult.success();
496
  }
497

498
  Future<int> _generateModule(Directory directory, Map<String, dynamic> templateContext, { bool overwrite = false }) async {
499 500
    int generatedCount = 0;
    final String description = argResults.wasParsed('description')
501
        ? stringArg('description')
502
        : 'A new flutter module project.';
503
    templateContext['description'] = description;
504
    generatedCount += _renderTemplate(globals.fs.path.join('module', 'common'), directory, templateContext, overwrite: overwrite);
505
    if (boolArg('pub')) {
506
      await pub.get(
507
        context: PubContext.create,
508
        directory: directory.path,
509
        offline: boolArg('offline'),
510
      );
511
      final FlutterProject project = FlutterProject.fromDirectory(directory);
512
      await project.ensureReadyForPlatformSpecificTooling(checkProjects: false);
513 514 515 516
    }
    return generatedCount;
  }

517
  Future<int> _generatePackage(Directory directory, Map<String, dynamic> templateContext, { bool overwrite = false }) async {
518 519
    int generatedCount = 0;
    final String description = argResults.wasParsed('description')
520
        ? stringArg('description')
521
        : 'A new Flutter package project.';
522
    templateContext['description'] = description;
523
    generatedCount += _renderTemplate('package', directory, templateContext, overwrite: overwrite);
524
    if (boolArg('pub')) {
525
      await pub.get(
526
        context: PubContext.createPackage,
527
        directory: directory.path,
528
        offline: boolArg('offline'),
529
      );
530
    }
531 532
    return generatedCount;
  }
533

534
  Future<int> _generatePlugin(Directory directory, Map<String, dynamic> templateContext, { bool overwrite = false }) async {
535 536
    int generatedCount = 0;
    final String description = argResults.wasParsed('description')
537
        ? stringArg('description')
538 539
        : 'A new flutter plugin project.';
    templateContext['description'] = description;
540
    generatedCount += _renderTemplate('plugin', directory, templateContext, overwrite: overwrite);
541
    if (boolArg('pub')) {
542
      await pub.get(
543
        context: PubContext.createPlugin,
544
        directory: directory.path,
545
        offline: boolArg('offline'),
546 547
      );
    }
548
    final FlutterProject project = FlutterProject.fromDirectory(directory);
549
    gradle.updateLocalProperties(project: project, requireAndroidSdk: false);
550

551 552 553
    final String projectName = templateContext['projectName'] as String;
    final String organization = templateContext['organization'] as String;
    final String androidPluginIdentifier = templateContext['androidIdentifier'] as String;
554 555 556 557 558 559 560 561
    final String exampleProjectName = projectName + '_example';
    templateContext['projectName'] = exampleProjectName;
    templateContext['androidIdentifier'] = _createAndroidIdentifier(organization, exampleProjectName);
    templateContext['iosIdentifier'] = _createUTIIdentifier(organization, exampleProjectName);
    templateContext['description'] = 'Demonstrates how to use the $projectName plugin.';
    templateContext['pluginProjectName'] = projectName;
    templateContext['androidPluginIdentifier'] = androidPluginIdentifier;

562
    generatedCount += await _generateApp(project.example.directory, templateContext, overwrite: overwrite);
563 564 565
    return generatedCount;
  }

566
  Future<int> _generateApp(Directory directory, Map<String, dynamic> templateContext, { bool overwrite = false }) async {
567
    int generatedCount = 0;
568
    generatedCount += _renderTemplate('app', directory, templateContext, overwrite: overwrite);
569
    final FlutterProject project = FlutterProject.fromDirectory(directory);
570
    generatedCount += _injectGradleWrapper(project);
571

572
    if (boolArg('with-driver-test')) {
573
      final Directory testDirectory = directory.childDirectory('test_driver');
574
      generatedCount += _renderTemplate('driver', testDirectory, templateContext, overwrite: overwrite);
575 576
    }

577 578
    if (boolArg('pub')) {
      await pub.get(context: PubContext.create, directory: directory.path, offline: boolArg('offline'));
579
      await project.ensureReadyForPlatformSpecificTooling(checkProjects: false);
580
    }
Hixie's avatar
Hixie committed
581

582
    gradle.updateLocalProperties(project: project, requireAndroidSdk: false);
583

584
    return generatedCount;
Hixie's avatar
Hixie committed
585
  }
586

587 588 589 590
  // Takes an application template and replaces the main.dart with one from the
  // documentation website in sampleCode.  Returns the difference in the number
  // of files after applying the sample, since it also deletes the application's
  // test directory (since the template's test doesn't apply to the sample).
591
  int _applySample(Directory directory, String sampleCode) {
592
    final File mainDartFile = directory.childDirectory('lib').childFile('main.dart');
593 594
    mainDartFile.createSync(recursive: true);
    mainDartFile.writeAsStringSync(sampleCode);
595 596
    final Directory testDir = directory.childDirectory('test');
    final List<FileSystemEntity> files = testDir.listSync(recursive: true);
597
    testDir.deleteSync(recursive: true);
598 599 600
    return -files.length;
  }

601
  Map<String, dynamic> _templateContext({
602
    String organization,
603 604 605
    String projectName,
    String projectDescription,
    String androidLanguage,
606
    bool androidX,
607 608
    String iosLanguage,
    String flutterRoot,
609 610
    bool renderDriverTest = false,
    bool withPluginHook = false,
611
    bool web = false,
612
    bool macos = false,
613
  }) {
614
    flutterRoot = globals.fs.path.normalize(flutterRoot);
615

616 617 618 619
    final String pluginDartClass = _createPluginClassName(projectName);
    final String pluginClass = pluginDartClass.endsWith('Plugin')
        ? pluginDartClass
        : pluginDartClass + 'Plugin';
620
    final String appleIdentifier = _createUTIIdentifier(organization, projectName);
621

622
    return <String, dynamic>{
623
      'organization': organization,
624
      'projectName': projectName,
625
      'androidIdentifier': _createAndroidIdentifier(organization, projectName),
626 627
      'iosIdentifier': appleIdentifier,
      'macosIdentifier': appleIdentifier,
628
      'description': projectDescription,
629
      'dartSdk': '$flutterRoot/bin/cache/dart-sdk',
630
      'androidX': androidX,
631
      'useAndroidEmbeddingV2': featureFlags.isAndroidEmbeddingV2Enabled,
632
      'androidMinApiLevel': android.minApiLevel,
633
      'androidSdkVersion': android_sdk.minimumAndroidSdkVersion,
634
      'androidFlutterJar': '$flutterRoot/bin/cache/artifacts/engine/android-arm/flutter.jar',
635 636 637 638
      'withDriverTest': renderDriverTest,
      'pluginClass': pluginClass,
      'pluginDartClass': pluginDartClass,
      'withPluginHook': withPluginHook,
639 640
      'androidLanguage': androidLanguage,
      'iosLanguage': iosLanguage,
641 642
      'flutterRevision': globals.flutterVersion.frameworkRevision,
      'flutterChannel': globals.flutterVersion.channel,
643
      'web': web,
644 645 646 647 648 649 650
      'macos': macos,
      'year': DateTime.now().year,
      // For now, the new plugin schema is only used when a desktop plugin is
      // enabled. Once the new schema is supported on stable, this should be
      // removed, and the new schema should always be used.
      'useNewPluginSchema': macos,
      // If a desktop platform is included, add a workaround for #31366.
651 652
      // When Linux and Windows are added, we will need this workaround again.
      'includeTargetPlatformWorkaround': false,
653
    };
654
  }
655

656
  int _renderTemplate(String templateName, Directory directory, Map<String, dynamic> context, { bool overwrite = false }) {
657
    final Template template = Template.fromName(templateName);
658
    return template.render(directory, context, overwriteExisting: overwrite);
659
  }
660

661
  int _injectGradleWrapper(FlutterProject project) {
662
    int filesCreated = 0;
663
    fsUtils.copyDirectorySync(
664
      globals.cache.getArtifactDirectory('gradle_wrapper'),
665
      project.android.hostAppGradleRoot,
666
      onFileCopied: (File sourceFile, File destinationFile) {
667 668 669 670 671 672 673 674 675
        filesCreated++;
        final String modes = sourceFile.statSync().modeString();
        if (modes != null && modes.contains('x')) {
          os.makeExecutable(destinationFile);
        }
      },
    );
    return filesCreated;
  }
676 677
}

678
String _createAndroidIdentifier(String organization, String name) {
679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704
  // Android application ID is specified in: https://developer.android.com/studio/build/application-id
  // All characters must be alphanumeric or an underscore [a-zA-Z0-9_].
  String tmpIdentifier = '$organization.$name';
  final RegExp disallowed = RegExp(r'[^\w\.]');
  tmpIdentifier = tmpIdentifier.replaceAll(disallowed, '');

  // It must have at least two segments (one or more dots).
  final List<String> segments = tmpIdentifier
      .split('.')
      .where((String segment) => segment.isNotEmpty)
      .toList();
  while (segments.length < 2) {
    segments.add('untitled');
  }

  // Each segment must start with a letter.
  final RegExp segmentPatternRegex = RegExp(r'^[a-zA-Z][\w]*$');
  final List<String> prefixedSegments = segments
      .map((String segment) {
        if (!segmentPatternRegex.hasMatch(segment)) {
          return 'u'+segment;
        }
        return segment;
      })
      .toList();
  return prefixedSegments.join('.');
705 706 707 708 709
}

String _createPluginClassName(String name) {
  final String camelizedName = camelCase(name);
  return camelizedName[0].toUpperCase() + camelizedName.substring(1);
710 711
}

712
String _createUTIIdentifier(String organization, String name) {
713
  // Create a UTI (https://en.wikipedia.org/wiki/Uniform_Type_Identifier) from a base name
714 715
  name = camelCase(name);
  String tmpIdentifier = '$organization.$name';
716
  final RegExp disallowed = RegExp(r'[^a-zA-Z0-9\-\.\u0080-\uffff]+');
717 718 719 720 721 722 723 724 725 726 727 728
  tmpIdentifier = tmpIdentifier.replaceAll(disallowed, '');

  // It must have at least two segments (one or more dots).
  final List<String> segments = tmpIdentifier
      .split('.')
      .where((String segment) => segment.isNotEmpty)
      .toList();
  while (segments.length < 2) {
    segments.add('untitled');
  }

  return segments.join('.');
729
}
730

731
const Set<String> _packageDependencies = <String>{
732
  'analyzer',
733 734 735 736
  'args',
  'async',
  'collection',
  'convert',
737
  'crypto',
738
  'flutter',
739 740
  'flutter_test',
  'front_end',
741
  'html',
742
  'http',
743
  'intl',
744 745 746
  'io',
  'isolate',
  'kernel',
747 748
  'logging',
  'matcher',
749
  'meta',
750 751 752 753 754 755 756
  'mime',
  'path',
  'plugin',
  'pool',
  'test',
  'utf',
  'watcher',
757
  'yaml',
758
};
759

760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844
// A valid Dart identifier.
// https://dart.dev/guides/language/language-tour#important-concepts
final RegExp _identifierRegExp = RegExp('[a-zA-Z_][a-zA-Z0-9_]*');

// non-contextual dart keywords.
//' https://dart.dev/guides/language/language-tour#keywords
const Set<String> _keywords = <String>{
  'abstract',
  'as',
  'assert',
  'async',
  'await',
  'break',
  'case',
  'catch',
  'class',
  'const',
  'continue',
  'covariant',
  'default',
  'deferred',
  'do',
  'dynamic',
  'else',
  'enum',
  'export',
  'extends',
  'extension',
  'external',
  'factory',
  'false',
  'final',
  'finally',
  'for',
  'function',
  'get',
  'hide',
  'if',
  'implements',
  'import',
  'in',
  'inout',
  'interface',
  'is',
  'late',
  'library',
  'mixin',
  'native',
  'new',
  'null',
  'of',
  'on',
  'operator',
  'out',
  'part',
  'patch',
  'required',
  'rethrow',
  'return',
  'set',
  'show',
  'source',
  'static',
  'super',
  'switch',
  'sync',
  'this',
  'throw',
  'true',
  'try',
  'typedef',
  'var',
  'void',
  'while',
  'with',
  'yield',
};

/// Whether [name] is a valid Pub package.
@visibleForTesting
bool isValidPackageName(String name) {
  final Match match = _identifierRegExp.matchAsPrefix(name);
  return match != null && match.end == name.length && !_keywords.contains(name);
}

845
/// Return null if the project name is legal. Return a validation message if
846 847
/// we should disallow the project name.
String _validateProjectName(String projectName) {
848 849 850
  if (!isValidPackageName(projectName)) {
    return '"$projectName" is not a valid Dart package name.\n\n'
      'See https://dart.dev/tools/pub/pubspec#name for more information.';
851
  }
852 853
  if (_packageDependencies.contains(projectName)) {
    return "Invalid project name: '$projectName' - this will conflict with Flutter "
854
      'package dependencies.';
855
  }
856 857
  return null;
}
858

859
/// Return null if the project directory is legal. Return a validation message
860
/// if we should disallow the directory name.
861
String _validateProjectDir(String dirPath, { String flutterRoot, bool overwrite = false }) {
862
  if (globals.fs.path.isWithin(flutterRoot, dirPath)) {
863
    return 'Cannot create a project within the Flutter SDK. '
864 865 866
      "Target directory '$dirPath' is within the Flutter SDK at '$flutterRoot'.";
  }

867 868
  // If the destination directory is actually a file, then we refuse to
  // overwrite, on the theory that the user probably didn't expect it to exist.
869
  if (globals.fs.isFileSync(dirPath)) {
870 871 872 873
    final String message = "Invalid project name: '$dirPath' - refers to an existing file.";
    return overwrite
      ? '$message Refusing to overwrite a file with a directory.'
      : message;
874 875
  }

876
  if (overwrite) {
877
    return null;
878
  }
879

880
  final FileSystemEntityType type = globals.fs.typeSync(dirPath);
881

882
  if (type != FileSystemEntityType.notFound) {
883
    switch (type) {
884
      case FileSystemEntityType.file:
885
        // Do not overwrite files.
886
        return "Invalid project name: '$dirPath' - file exists.";
887
      case FileSystemEntityType.link:
888
        // Do not overwrite links.
889
        return "Invalid project name: '$dirPath' - refers to a link.";
890 891
    }
  }
892

893 894
  return null;
}