// 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 components, List 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 XmlException { 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 mapping = {}; for (final DeferredComponent component in components) { component.assignLoadingUnits(generatedLoadingUnits); final Set? 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(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 generatedLoadingUnits) { final List cachedLoadingUnits = _parseLoadingUnitsCache( projectDir.childFile(DeferredComponentsValidator.kLoadingUnitsCacheFileName), ); loadingUnitComparisonResults = {}; final Set unmatchedLoadingUnits = {}; final List newLoadingUnits = []; unmatchedLoadingUnits.addAll(cachedLoadingUnits); final Set addedNewIds = {}; 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 _parseLoadingUnitsCache(File cacheFile) { final List loadingUnits = []; 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'] as List) { 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'] as List) { final YamlMap? loadingUnitDataMap = loadingUnitData as YamlMap?; final List libraries = []; final YamlList? nodes = loadingUnitDataMap?['libraries'] as YamlList?; if (nodes != null) { for (final Object node in nodes.whereType()) { libraries.add(node as String); } } loadingUnits.add(LoadingUnit(id: loadingUnitDataMap!['id'] as int, 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? generatedLoadingUnits) { generatedLoadingUnits ??= []; 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 usedIds = {}; for (final LoadingUnit unit in generatedLoadingUnits) { if (usedIds.contains(unit.id)) { continue; } buffer.write(' - id: ${unit.id}\n'); if (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); } }