| // 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:xml/xml.dart'; |
| import 'package:yaml/yaml.dart'; |
| |
| import '../base/deferred_component.dart'; |
| import '../base/error_handling_io.dart'; |
| import '../base/file_system.dart'; |
| import '../build_system/build_system.dart'; |
| import 'deferred_components_validator.dart'; |
| |
| /// A class to configure and run deferred component setup verification checks |
| /// and tasks. |
| /// |
| /// Once constructed, checks and tasks can be executed by calling the respective |
| /// methods. The results of the checks are stored internally and can be |
| /// displayed to the user by calling [displayResults]. |
| class DeferredComponentsGenSnapshotValidator extends DeferredComponentsValidator { |
| /// Constructs a validator instance. |
| /// |
| /// The [env] property is used to locate the project files that are checked. |
| /// |
| /// The [templatesDir] parameter is optional. If null, the tool's default |
| /// templates directory will be used. |
| /// |
| /// When [exitOnFail] is set to true, the [handleResults] and [attemptToolExit] |
| /// methods will exit the tool when this validator detects a recommended |
| /// change. This defaults to true. |
| DeferredComponentsGenSnapshotValidator(this.env, { |
| bool exitOnFail = true, |
| String? title, |
| }) : super(env.projectDir, env.logger, env.platform, exitOnFail: exitOnFail, title: title); |
| |
| /// The build environment that should be used to find the input files to run |
| /// checks against. |
| /// |
| /// The checks in this class are meant to be used as part of a build process, |
| /// so an environment should be available. |
| final Environment env; |
| |
| // The key used to identify the metadata element as the loading unit id to |
| // deferred component mapping. |
| static const String _mappingKey = 'io.flutter.embedding.engine.deferredcomponents.DeferredComponentManager.loadingUnitMapping'; |
| |
| /// Checks if the base module `app`'s `AndroidManifest.xml` contains the |
| /// required meta-data that maps loading units to deferred components. |
| /// |
| /// Returns true if the check passed with no recommended changes, and false |
| /// otherwise. |
| /// |
| /// Flutter engine uses a manifest meta-data mapping to determine which |
| /// deferred component includes a particular loading unit id. This method |
| /// checks if `app`'s `AndroidManifest.xml` contains this metadata. If not, it |
| /// will generate a modified AndroidManifest.xml with the correct metadata |
| /// entry. |
| /// |
| /// An example mapping: |
| /// |
| /// 2:componentA,3:componentB,4:componentC |
| /// |
| /// Where loading unit 2 is included in componentA, loading unit 3 is included |
| /// in componentB, and loading unit 4 is included in componentC. |
| bool checkAppAndroidManifestComponentLoadingUnitMapping(List<DeferredComponent> components, List<LoadingUnit> generatedLoadingUnits) { |
| final Directory androidDir = projectDir.childDirectory('android'); |
| inputs.add(projectDir.childFile('pubspec.yaml')); |
| |
| // We do not use the Xml package to handle the writing, as we do not want to |
| // erase any user applied formatting and comments. The changes can be |
| // applied with dart io and custom parsing. |
| final File appManifestFile = androidDir |
| .childDirectory('app') |
| .childDirectory('src') |
| .childDirectory('main') |
| .childFile('AndroidManifest.xml'); |
| inputs.add(appManifestFile); |
| if (!appManifestFile.existsSync()) { |
| invalidFiles[appManifestFile.path] = 'Error: $appManifestFile does not ' |
| 'exist or could not be found. Please ensure an AndroidManifest.xml ' |
| "exists for the app's base module."; |
| return false; |
| } |
| XmlDocument document; |
| try { |
| document = XmlDocument.parse(appManifestFile.readAsStringSync()); |
| } on XmlParserException { |
| invalidFiles[appManifestFile.path] = 'Error parsing $appManifestFile ' |
| 'Please ensure that the android manifest is a valid XML document and ' |
| 'try again.'; |
| return false; |
| } on FileSystemException { |
| invalidFiles[appManifestFile.path] = 'Error reading $appManifestFile ' |
| 'even though it exists. Please ensure that you have read permission for ' |
| 'this file and try again.'; |
| return false; |
| } |
| // Create loading unit mapping. |
| final Map<int, String> mapping = <int, String>{}; |
| for (final DeferredComponent component in components) { |
| component.assignLoadingUnits(generatedLoadingUnits); |
| final Set<LoadingUnit>? loadingUnits = component.loadingUnits; |
| if (loadingUnits == null) { |
| continue; |
| } |
| for (final LoadingUnit unit in loadingUnits) { |
| if (!mapping.containsKey(unit.id)) { |
| mapping[unit.id] = component.name; |
| } |
| } |
| } |
| for (final LoadingUnit unit in generatedLoadingUnits) { |
| if (!mapping.containsKey(unit.id)) { |
| // Store an empty string for unassigned loading units, |
| // indicating that it is in the base component. |
| mapping[unit.id] = ''; |
| } |
| } |
| // Encode the mapping as a string. |
| final StringBuffer mappingBuffer = StringBuffer(); |
| for (final int key in mapping.keys) { |
| mappingBuffer.write('$key:${mapping[key]},'); |
| } |
| String encodedMapping = mappingBuffer.toString(); |
| // remove trailing comma if any |
| if (encodedMapping.endsWith(',')) { |
| encodedMapping = encodedMapping.substring(0, encodedMapping.length - 1); |
| } |
| // Check for existing metadata entry and see if needs changes. |
| bool exists = false; |
| bool modified = false; |
| for (final XmlElement application in document.findAllElements('application')) { |
| for (final XmlElement metaData in application.findElements('meta-data')) { |
| final String? name = metaData.getAttribute('android:name'); |
| if (name == _mappingKey) { |
| exists = true; |
| final String? storedMappingString = metaData.getAttribute('android:value'); |
| if (storedMappingString != encodedMapping) { |
| metaData.setAttribute('android:value', encodedMapping); |
| modified = true; |
| } |
| } |
| } |
| } |
| if (!exists) { |
| // Create an meta-data XmlElement that contains the mapping. |
| final XmlElement mappingMetadataElement = XmlElement(XmlName.fromString('meta-data'), |
| <XmlAttribute>[ |
| XmlAttribute(XmlName.fromString('android:name'), _mappingKey), |
| XmlAttribute(XmlName.fromString('android:value'), encodedMapping), |
| ], |
| ); |
| for (final XmlElement application in document.findAllElements('application')) { |
| application.children.add(mappingMetadataElement); |
| break; |
| } |
| } |
| if (!exists || modified) { |
| final File manifestOutput = outputDir |
| .childDirectory('app') |
| .childDirectory('src') |
| .childDirectory('main') |
| .childFile('AndroidManifest.xml'); |
| ErrorHandlingFileSystem.deleteIfExists(manifestOutput); |
| manifestOutput.createSync(recursive: true); |
| manifestOutput.writeAsStringSync(document.toXmlString(pretty: true), flush: true); |
| modifiedFiles.add(manifestOutput.path); |
| return false; |
| } |
| return true; |
| } |
| |
| /// Compares the provided loading units against the contents of the |
| /// `deferred_components_loading_units.yaml` file. |
| /// |
| /// Returns true if a loading unit cache file exists and all loading units |
| /// match, and false otherwise. |
| /// |
| /// This method will parse the cached loading units file if it exists and |
| /// compare it to the provided generatedLoadingUnits. It will distinguish |
| /// between newly added loading units and no longer existing loading units. If |
| /// the cache file does not exist, then all generatedLoadingUnits will be |
| /// considered new. |
| bool checkAgainstLoadingUnitsCache( |
| List<LoadingUnit> generatedLoadingUnits) { |
| final List<LoadingUnit> cachedLoadingUnits = _parseLoadingUnitsCache(projectDir.childFile(DeferredComponentsValidator.kLoadingUnitsCacheFileName)); |
| loadingUnitComparisonResults = <String, Object>{}; |
| final Set<LoadingUnit> unmatchedLoadingUnits = <LoadingUnit>{}; |
| final List<LoadingUnit> newLoadingUnits = <LoadingUnit>[]; |
| if (generatedLoadingUnits == null || cachedLoadingUnits == null) { |
| loadingUnitComparisonResults!['new'] = newLoadingUnits; |
| loadingUnitComparisonResults!['missing'] = unmatchedLoadingUnits; |
| loadingUnitComparisonResults!['match'] = false; |
| return false; |
| } |
| unmatchedLoadingUnits.addAll(cachedLoadingUnits); |
| final Set<int> addedNewIds = <int>{}; |
| for (final LoadingUnit genUnit in generatedLoadingUnits) { |
| bool matched = false; |
| for (final LoadingUnit cacheUnit in cachedLoadingUnits) { |
| if (genUnit.equalsIgnoringPath(cacheUnit)) { |
| matched = true; |
| unmatchedLoadingUnits.remove(cacheUnit); |
| break; |
| } |
| } |
| if (!matched && !addedNewIds.contains(genUnit.id)) { |
| newLoadingUnits.add(genUnit); |
| addedNewIds.add(genUnit.id); |
| } |
| } |
| loadingUnitComparisonResults!['new'] = newLoadingUnits; |
| loadingUnitComparisonResults!['missing'] = unmatchedLoadingUnits; |
| loadingUnitComparisonResults!['match'] = newLoadingUnits.isEmpty && unmatchedLoadingUnits.isEmpty; |
| return loadingUnitComparisonResults!['match']! as bool; |
| } |
| |
| List<LoadingUnit> _parseLoadingUnitsCache(File cacheFile) { |
| final List<LoadingUnit> loadingUnits = <LoadingUnit>[]; |
| inputs.add(cacheFile); |
| if (!cacheFile.existsSync()) { |
| return loadingUnits; |
| } |
| final YamlMap data = loadYaml(cacheFile.readAsStringSync()) as YamlMap; |
| // validate yaml format. |
| if (!data.containsKey('loading-units')) { |
| invalidFiles[cacheFile.path] = "Invalid loading units yaml file, 'loading-units' " |
| 'entry did not exist.'; |
| return loadingUnits; |
| } else { |
| if (data['loading-units'] is! YamlList && data['loading-units'] != null) { |
| invalidFiles[cacheFile.path] = "Invalid loading units yaml file, 'loading-units' " |
| 'is not a list.'; |
| return loadingUnits; |
| } |
| if (data['loading-units'] != null) { |
| for (final Object? loadingUnitData in data['loading-units']) { |
| if (loadingUnitData is! YamlMap) { |
| invalidFiles[cacheFile.path] = "Invalid loading units yaml file, 'loading-units' " |
| 'is not a list of maps.'; |
| return loadingUnits; |
| } |
| final YamlMap loadingUnitDataMap = loadingUnitData; |
| if (loadingUnitDataMap['id'] == null) { |
| invalidFiles[cacheFile.path] = 'Invalid loading units yaml file, all ' |
| "loading units must have an 'id'"; |
| return loadingUnits; |
| } |
| if (loadingUnitDataMap['libraries'] != null) { |
| if (loadingUnitDataMap['libraries'] is! YamlList) { |
| invalidFiles[cacheFile.path] = "Invalid loading units yaml file, 'libraries' " |
| 'is not a list.'; |
| return loadingUnits; |
| } |
| for (final Object? node in loadingUnitDataMap['libraries'] as YamlList) { |
| if (node is! String) { |
| invalidFiles[cacheFile.path] = "Invalid loading units yaml file, 'libraries' " |
| 'is not a list of strings.'; |
| return loadingUnits; |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| // Parse out validated yaml. |
| if (data.containsKey('loading-units')) { |
| if (data['loading-units'] != null) { |
| for (final Object? loadingUnitData in data['loading-units']) { |
| final YamlMap? loadingUnitDataMap = loadingUnitData as YamlMap?; |
| final List<String> libraries = <String>[]; |
| final YamlList? nodes = loadingUnitDataMap?['libraries'] as YamlList?; |
| if (nodes != null) { |
| for (final Object node in nodes.whereType<Object>()) { |
| libraries.add(node as String); |
| } |
| } |
| loadingUnits.add( |
| LoadingUnit( |
| id: loadingUnitDataMap!['id'] as int, |
| path: null, |
| libraries: libraries, |
| )); |
| } |
| } |
| } |
| return loadingUnits; |
| } |
| |
| /// Writes the provided generatedLoadingUnits as `deferred_components_loading_units.yaml` |
| /// |
| /// This cache file is used to detect any changes in the loading units |
| /// produced by gen_snapshot. Running [checkAgainstLoadingUnitCache] with a |
| /// mismatching or missing cache will result in a failed validation. This |
| /// prevents unexpected changes in loading units causing misconfigured |
| /// deferred components. |
| void writeLoadingUnitsCache(List<LoadingUnit>? generatedLoadingUnits) { |
| generatedLoadingUnits ??= <LoadingUnit>[]; |
| final File cacheFile = projectDir.childFile(DeferredComponentsValidator.kLoadingUnitsCacheFileName); |
| outputs.add(cacheFile); |
| ErrorHandlingFileSystem.deleteIfExists(cacheFile); |
| cacheFile.createSync(recursive: true); |
| |
| final StringBuffer buffer = StringBuffer(); |
| buffer.write(''' |
| # ============================================================================== |
| # The contents of this file are automatically generated and it is not |
| # recommended to modify this file manually. |
| # ============================================================================== |
| # |
| # In order to prevent unexpected splitting of deferred apps, this file records |
| # the last generated set of loading units. It only possible to obtain the final |
| # configuration of loading units after compilation is complete. This means |
| # improperly setup deferred imports can only be detected after compilation. |
| # |
| # This file allows the build tool to detect any changes in the generated |
| # loading units. During the next build attempt, loading units in this file are |
| # compared against the newly generated loading units to check for any new or |
| # removed loading units. In the case where loading units do not match, the build |
| # will fail and ask the developer to verify that the `deferred-components` |
| # configuration in `pubspec.yaml` is correct. Developers should make any |
| # necessary changes to integrate new and changed loading units or remove no |
| # longer existing loading units from the configuration. The build command should |
| # then be re-run to continue the build process. |
| # |
| # Sometimes, changes to the generated loading units may be unintentional. If |
| # the list of loading units in this file is not what is expected, the app's |
| # deferred imports should be reviewed. Third party plugins and packages may |
| # also introduce deferred imports that result in unexpected loading units. |
| loading-units: |
| '''); |
| final Set<int> usedIds = <int>{}; |
| for (final LoadingUnit unit in generatedLoadingUnits) { |
| if (usedIds.contains(unit.id)) { |
| continue; |
| } |
| buffer.write(' - id: ${unit.id}\n'); |
| if (unit.libraries != null && unit.libraries.isNotEmpty) { |
| buffer.write(' libraries:\n'); |
| for (final String lib in unit.libraries) { |
| buffer.write(' - $lib\n'); |
| } |
| } |
| usedIds.add(unit.id); |
| } |
| cacheFile.writeAsStringSync(buffer.toString(), flush: true); |
| } |
| } |