flutter_project_metadata.dart 13.5 KB
Newer Older
1 2 3 4 5 6 7 8 9
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:yaml/yaml.dart';

import 'base/file_system.dart';
import 'base/logger.dart';
import 'base/utils.dart';
10
import 'features.dart';
11
import 'project.dart';
12
import 'template.dart';
13
import 'version.dart';
14

15
enum FlutterProjectType implements CliEnum {
16 17 18 19
  /// 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.
  app,
20

21 22
  /// A List/Detail app template that follows community best practices.
  skeleton,
23

24 25 26
  /// The is a project that has managed platform host code. It is an application with
  /// ephemeral .ios and .android directories that can be updated automatically.
  module,
27

28 29 30
  /// This is a Flutter Dart package project. It doesn't have any native
  /// components, only Dart.
  package,
31

32 33 34
  /// This is a Dart package project with external builds for native components.
  packageFfi,

35 36
  /// This is a native plugin project.
  plugin,
37

Daco Harkes's avatar
Daco Harkes committed
38
  /// This is an FFI native plugin project.
39
  pluginFfi;
40

41 42
  @override
  String get cliName => snakeCase(name);
43

44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
  @override
  String get helpText => switch (this) {
        FlutterProjectType.app => '(default) Generate a Flutter application.',
        FlutterProjectType.skeleton =>
          'Generate a List View / Detail View Flutter application that follows community best practices.',
        FlutterProjectType.package =>
          'Generate a shareable Flutter project containing modular Dart code.',
        FlutterProjectType.plugin =>
          'Generate a shareable Flutter project containing an API '
          'in Dart code with a platform-specific implementation through method channels for Android, iOS, '
          'Linux, macOS, Windows, web, or any combination of these.',
        FlutterProjectType.pluginFfi =>
          'Generate a shareable Flutter project containing an API '
          'in Dart code with a platform-specific implementation through dart:ffi for Android, iOS, '
          'Linux, macOS, Windows, or any combination of these.',
59 60 61 62
        FlutterProjectType.packageFfi =>
          'Generate a shareable Dart/Flutter project containing an API '
          'in Dart code with a platform-specific implementation through dart:ffi for Android, iOS, '
          'Linux, macOS, and Windows.',
63 64 65 66 67 68 69 70 71
        FlutterProjectType.module =>
          'Generate a project to add a Flutter module to an existing Android or iOS application.',
      };

  static FlutterProjectType? fromCliName(String value) {
    for (final FlutterProjectType type in FlutterProjectType.values) {
      if (value == type.cliName) {
        return type;
      }
72
    }
73
    return null;
74
  }
75 76 77 78 79 80 81 82 83 84

  static List<FlutterProjectType> get enabledValues {
    return <FlutterProjectType>[
      for (final FlutterProjectType value in values)
        if (value == FlutterProjectType.packageFfi) ...<FlutterProjectType>[
          if (featureFlags.isNativeAssetsEnabled) value
        ] else
          value,
    ];
  }
85 86
}

87
  /// Verifies the expected yaml keys are present in the file.
88
  bool _validateMetadataMap(YamlMap map, Map<String, Type> validations, Logger logger) {
89 90 91 92 93 94 95
    bool isValid = true;
    for (final MapEntry<String, Object> entry in validations.entries) {
      if (!map.keys.contains(entry.key)) {
        isValid = false;
        logger.printTrace('The key `${entry.key}` was not found');
        break;
      }
96 97
      final Object? metadataValue = map[entry.key];
      if (metadataValue.runtimeType != entry.value) {
98
        isValid = false;
99
        logger.printTrace('The value of key `${entry.key}` in .metadata was expected to be ${entry.value} but was ${metadataValue.runtimeType}');
100 101 102 103 104 105
        break;
      }
    }
    return isValid;
  }

106 107
/// A wrapper around the `.metadata` file.
class FlutterProjectMetadata {
108
  /// Creates a MigrateConfig by parsing an existing .migrate_config yaml file.
109
  FlutterProjectMetadata(this.file, Logger logger) : _logger = logger,
110
                                                     migrateConfig = MigrateConfig() {
111 112
    if (!file.existsSync()) {
      _logger.printTrace('No .metadata file found at ${file.path}.');
113 114 115 116 117
      // Create a default empty metadata.
      return;
    }
    Object? yamlRoot;
    try {
118
      yamlRoot = loadYaml(file.readAsStringSync());
119 120 121
    } on YamlException {
      // Handled in _validate below.
    }
122
    if (yamlRoot is! YamlMap) {
123
      _logger.printTrace('.metadata file at ${file.path} was empty or malformed.');
124 125 126
      return;
    }
    if (_validateMetadataMap(yamlRoot, <String, Type>{'version': YamlMap}, _logger)) {
127 128
      final Object? versionYamlMap = yamlRoot['version'];
      if (versionYamlMap is YamlMap && _validateMetadataMap(versionYamlMap, <String, Type>{
129 130 131 132 133 134 135 136
            'revision': String,
            'channel': String,
          }, _logger)) {
        _versionRevision = versionYamlMap['revision'] as String?;
        _versionChannel = versionYamlMap['channel'] as String?;
      }
    }
    if (_validateMetadataMap(yamlRoot, <String, Type>{'project_type': String}, _logger)) {
137
      _projectType = FlutterProjectType.fromCliName(yamlRoot['project_type'] as String);
138
    }
139 140 141
    final Object? migrationYaml = yamlRoot['migration'];
    if (migrationYaml is YamlMap) {
      migrateConfig.parseYaml(migrationYaml, _logger);
142 143 144
    }
  }

145
  /// Creates a FlutterProjectMetadata by explicitly providing all values.
146
  FlutterProjectMetadata.explicit({
147
    required this.file,
148 149 150 151 152 153 154 155
    required String? versionRevision,
    required String? versionChannel,
    required FlutterProjectType? projectType,
    required this.migrateConfig,
    required Logger logger,
  }) : _logger = logger,
       _versionChannel = versionChannel,
       _versionRevision = versionRevision,
156
       _projectType = projectType;
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171

  /// The name of the config file.
  static const String kFileName = '.metadata';

  String? _versionRevision;
  String? get versionRevision => _versionRevision;

  String? _versionChannel;
  String? get versionChannel => _versionChannel;

  FlutterProjectType? _projectType;
  FlutterProjectType? get projectType => _projectType;

  /// Metadata and configuration for the migrate command.
  MigrateConfig migrateConfig;
172 173 174

  final Logger _logger;

175
  final File file;
176 177 178 179 180 181

  /// Writes the .migrate_config file in the provided project directory's platform subdirectory.
  ///
  /// We write the file manually instead of with a template because this
  /// needs to be able to write the .migrate_config file into legacy apps.
  void writeFile({File? outputFile}) {
182
    outputFile = outputFile ?? file;
183 184
    outputFile
      ..createSync(recursive: true)
185 186 187 188 189 190
      ..writeAsStringSync(toString(), flush: true);
  }

  @override
  String toString() {
    return '''
191 192 193
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
194
# This file should be version controlled and should not be manually edited.
195 196

version:
197 198
  revision: ${escapeYamlString(_versionRevision ?? '')}
  channel: ${escapeYamlString(_versionChannel ?? kUserBranch)}
199

200
project_type: ${projectType == null ? '' : projectType!.cliName}
201
${migrateConfig.getOutputFileString()}''';
202 203 204 205
  }

  void populate({
    List<SupportedPlatform>? platforms,
206
    required Directory projectDirectory,
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222
    String? currentRevision,
    String? createRevision,
    bool create = true,
    bool update = true,
    required Logger logger,
  }) {
    migrateConfig.populate(
      platforms: platforms,
      projectDirectory: projectDirectory,
      currentRevision: currentRevision,
      createRevision: createRevision,
      create: create,
      update: update,
      logger: logger,
    );
  }
223

224 225 226 227 228
  /// Finds the fallback revision to use when no base revision is found in the migrate config.
  String getFallbackBaseRevision(Logger logger, FlutterVersion flutterVersion) {
    // Use the .metadata file if it exists.
    if (versionRevision != null) {
      return versionRevision!;
229
    }
230
    return flutterVersion.frameworkRevision;
231
  }
232 233 234 235 236 237 238 239 240 241 242 243
}

/// Represents the migrate command metadata section of a .metadata file.
///
/// This file tracks the flutter sdk git hashes of the last successful migration ('base') and
/// the version the project was created with.
///
/// Each platform tracks a different set of revisions because flutter create can be
/// used to add support for new platforms, so the base and create revision may not always be the same.
class MigrateConfig {
  MigrateConfig({
    Map<SupportedPlatform, MigratePlatformConfig>? platformConfigs,
244
    this.unmanagedFiles = kDefaultUnmanagedFiles
245 246
  }) : platformConfigs = platformConfigs ?? <SupportedPlatform, MigratePlatformConfig>{};

Lioness100's avatar
Lioness100 committed
247
  /// A mapping of the files that are unmanaged by default for each platform.
248
  static const List<String> kDefaultUnmanagedFiles = <String>[
249 250 251 252 253 254
    'lib/main.dart',
    'ios/Runner.xcodeproj/project.pbxproj',
  ];

  /// The metadata for each platform supported by the project.
  final Map<SupportedPlatform, MigratePlatformConfig> platformConfigs;
255

256 257 258 259 260
  /// A list of paths relative to this file the migrate tool should ignore.
  ///
  /// These files are typically user-owned files that should not be changed.
  List<String> unmanagedFiles;

261
  bool get isEmpty => platformConfigs.isEmpty && (unmanagedFiles.isEmpty || unmanagedFiles == kDefaultUnmanagedFiles);
262 263 264 265 266

  /// Parses the project for all supported platforms and populates the [MigrateConfig]
  /// to reflect the project.
  void populate({
    List<SupportedPlatform>? platforms,
267
    required Directory projectDirectory,
268 269 270 271 272 273
    String? currentRevision,
    String? createRevision,
    bool create = true,
    bool update = true,
    required Logger logger,
  }) {
274
    final FlutterProject flutterProject = FlutterProject.fromDirectory(projectDirectory);
275 276 277 278 279 280 281
    platforms ??= flutterProject.getSupportedPlatforms(includeRoot: true);

    for (final SupportedPlatform platform in platforms) {
      if (platformConfigs.containsKey(platform)) {
        if (update) {
          platformConfigs[platform]!.baseRevision = currentRevision;
        }
282
      } else {
283
        if (create) {
284
          platformConfigs[platform] = MigratePlatformConfig(platform: platform, createRevision: createRevision, baseRevision: currentRevision);
285
        }
286 287
      }
    }
288 289 290 291 292 293 294 295 296 297 298 299
  }

  /// Returns the string that should be written to the .metadata file.
  String getOutputFileString() {
    String unmanagedFilesString = '';
    for (final String path in unmanagedFiles) {
      unmanagedFilesString += "\n    - '$path'";
    }

    String platformsString = '';
    for (final MapEntry<SupportedPlatform, MigratePlatformConfig> entry in platformConfigs.entries) {
      platformsString += '\n    - platform: ${entry.key.toString().split('.').last}\n      create_revision: ${entry.value.createRevision == null ? 'null' : "${entry.value.createRevision}"}\n      base_revision: ${entry.value.baseRevision == null ? 'null' : "${entry.value.baseRevision}"}';
300
    }
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315

    return isEmpty ? '' : '''

# Tracks metadata for the flutter migrate command
migration:
  platforms:$platformsString

  # User provided section

  # List of Local paths (relative to this file) that should be
  # ignored by the migrate tool.
  #
  # Files that are not part of the templates will be ignored by default.
  unmanaged_files:$unmanagedFilesString
''';
316 317
  }

318 319 320 321 322
  /// Parses and validates the `migration` section of the .metadata file.
  void parseYaml(YamlMap map, Logger logger) {
    final Object? platformsYaml = map['platforms'];
    if (_validateMetadataMap(map, <String, Type>{'platforms': YamlList}, logger)) {
      if (platformsYaml is YamlList && platformsYaml.isNotEmpty) {
323 324
        for (final YamlMap platformYamlMap in platformsYaml.whereType<YamlMap>()) {
          if (_validateMetadataMap(platformYamlMap, <String, Type>{
325 326 327 328
                'platform': String,
                'create_revision': String,
                'base_revision': String,
              }, logger)) {
329
            final SupportedPlatform platformValue = SupportedPlatform.values.firstWhere(
330 331
              (SupportedPlatform val) => val.toString() == 'SupportedPlatform.${platformYamlMap['platform'] as String}'
            );
332 333
            platformConfigs[platformValue] = MigratePlatformConfig(
              platform: platformValue,
334 335 336 337 338 339 340 341
              createRevision: platformYamlMap['create_revision'] as String?,
              baseRevision: platformYamlMap['base_revision'] as String?,
            );
          } else {
            // malformed platform entry
            continue;
          }
        }
342
      }
343 344 345 346 347
    }
    if (_validateMetadataMap(map, <String, Type>{'unmanaged_files': YamlList}, logger)) {
      final Object? unmanagedFilesYaml = map['unmanaged_files'];
      if (unmanagedFilesYaml is YamlList && unmanagedFilesYaml.isNotEmpty) {
        unmanagedFiles = List<String>.from(unmanagedFilesYaml.value.cast<String>());
348 349 350 351
      }
    }
  }
}
352 353 354

/// Holds the revisions for a single platform for use by the flutter migrate command.
class MigratePlatformConfig {
355 356 357 358 359 360 361 362
  MigratePlatformConfig({
    required this.platform,
    this.createRevision,
    this.baseRevision
  });

  /// The platform this config describes.
  SupportedPlatform platform;
363 364 365 366 367 368 369 370 371 372

  /// The Flutter SDK revision this platform was created by.
  ///
  /// Null if the initial create git revision is unknown.
  final String? createRevision;

  /// The Flutter SDK revision this platform was last migrated by.
  ///
  /// Null if the project was never migrated or the revision is unknown.
  String? baseRevision;
373 374 375 376 377 378

  bool equals(MigratePlatformConfig other) {
    return platform == other.platform &&
           createRevision == other.createRevision &&
           baseRevision == other.baseRevision;
  }
379
}