blob: 90acc22bc438f52e018fa4fd1d5fb7bf7b11ef6b [file] [log] [blame]
// 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 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<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'] as List<Object?>) {
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<Object?>) {
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,
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);
}
}