// 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 '../base/deferred_component.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; import '../globals.dart' as globals; import '../project.dart'; import '../template.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 DeferredComponentsPrebuildValidator extends DeferredComponentsValidator { /// Constructs a validator instance. /// /// 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. DeferredComponentsPrebuildValidator( super.projectDir, super.logger, super.platform, { super.exitOnFail, super.title, Directory? templatesDir, }) : _templatesDir = templatesDir; final Directory? _templatesDir; /// Checks if an android dynamic feature module exists for each deferred /// component. /// /// Returns true if the check passed with no recommended changes, and false /// otherwise. /// /// This method looks for the existence of `android//build.gradle` /// and `android//src/main/AndroidManifest.xml`. If either of /// these files does not exist, it will generate it in the validator output /// directory based off of a template. /// /// This method does not check if the contents of either of the files are /// valid, as there are many ways that they can be validly configured. Future checkAndroidDynamicFeature(List components) async { inputs.add(projectDir.childFile('pubspec.yaml')); if (components.isEmpty) { return false; } var changesMade = false; for (final component in components) { final androidFiles = _DeferredComponentAndroidFiles( name: component.name, projectDir: projectDir, logger: logger, templatesDir: _templatesDir, ); if (!androidFiles.verifyFilesExist()) { // generate into temp directory final Map> results = await androidFiles.generateFiles( alternateAndroidDir: outputDir, clearAlternateOutputDir: true, ); if (results.containsKey('outputs')) { for (final File file in results['outputs']!) { generatedFiles.add(file.path); changesMade = true; } outputs.addAll(results['outputs']!); } if (results.containsKey('inputs')) { inputs.addAll(results['inputs']!); } } } return !changesMade; } /// Checks if the base module `app`'s `strings.xml` contain string /// resources for each component's name. /// /// Returns true if the check passed with no recommended changes, and false /// otherwise. /// /// In each dynamic feature module's AndroidManifest.xml, the /// name of the module is a string resource. This checks if /// the needed string resources are in the base module `strings.xml`. /// If not, this method will generate a modified `strings.xml` (or a /// completely new one if the original file did not exist) in the /// validator's output directory. /// /// For example, if there is a deferred component named `component1`, /// there should be the following string resource: /// /// ```xml /// component1 /// ``` /// /// The string element's name attribute should be the component name with /// `Name` as a suffix, and the text contents should be the component name. bool checkAndroidResourcesStrings(List components) { final Directory androidDir = projectDir.childDirectory('android'); inputs.add(projectDir.childFile('pubspec.yaml')); // Add component name mapping to strings.xml final File stringRes = androidDir .childDirectory('app') .childDirectory('src') .childDirectory('main') .childDirectory('res') .childDirectory('values') .childFile('strings.xml'); inputs.add(stringRes); final File stringResOutput = outputDir .childDirectory('app') .childDirectory('src') .childDirectory('main') .childDirectory('res') .childDirectory('values') .childFile('strings.xml'); ErrorHandlingFileSystem.deleteIfExists(stringResOutput); if (components.isEmpty) { return true; } final requiredEntriesMap = {}; for (final component in components) { requiredEntriesMap['${component.name}Name'] = component.name; } if (stringRes.existsSync()) { var modified = false; XmlDocument document; try { document = XmlDocument.parse(stringRes.readAsStringSync()); } on XmlException { invalidFiles[stringRes.path] = 'Error parsing $stringRes ' 'Please ensure that the strings.xml is a valid XML document and ' 'try again.'; return false; } // Check if all required lines are present, and fix if name exists, but // wrong string stored. for (final XmlElement resources in document.findAllElements('resources')) { for (final XmlElement element in resources.findElements('string')) { final String? name = element.getAttribute('name'); if (requiredEntriesMap.containsKey(name)) { if (element.innerText != requiredEntriesMap[name]) { element.innerText = requiredEntriesMap[name]!; modified = true; } requiredEntriesMap.remove(name); } } requiredEntriesMap.forEach((String key, String value) { modified = true; final newStringElement = XmlElement( XmlName.fromString('string'), [XmlAttribute(XmlName.fromString('name'), key)], [XmlText(value)], ); resources.children.add(newStringElement); }); break; } if (modified) { stringResOutput.createSync(recursive: true); stringResOutput.writeAsStringSync(document.toXmlString(pretty: true)); modifiedFiles.add(stringResOutput.path); return false; } return true; } // strings.xml does not exist, generate completely new file. stringResOutput.createSync(recursive: true); final buffer = StringBuffer(); buffer.writeln(''' '''); for (final String key in requiredEntriesMap.keys) { buffer.write(' ${requiredEntriesMap[key]}\n'); } buffer.write(''' '''); stringResOutput.writeAsStringSync(buffer.toString(), flush: true, mode: FileMode.append); generatedFiles.add(stringResOutput.path); return false; } /// Deletes all files inside of the validator's output directory. void clearOutputDir() { final Directory dir = projectDir .childDirectory('build') .childDirectory(DeferredComponentsValidator.kDeferredComponentsTempDirectory); ErrorHandlingFileSystem.deleteIfExists(dir, recursive: true); } } // Handles a single deferred component's android dynamic feature module // directory. class _DeferredComponentAndroidFiles { _DeferredComponentAndroidFiles({ required this.name, required this.projectDir, required this.logger, Directory? templatesDir, }) : _templatesDir = templatesDir; // The name of the deferred component. final String name; final Directory projectDir; final Logger logger; final Directory? _templatesDir; Directory get androidDir => projectDir.childDirectory('android'); Directory get componentDir => androidDir.childDirectory(name); File get androidManifestFile => componentDir.childDirectory('src').childDirectory('main').childFile('AndroidManifest.xml'); File get buildGradleFile { if (componentDir.childFile('build.gradle').existsSync()) { return componentDir.childFile('build.gradle'); } return componentDir.childFile('build.gradle.kts'); } // True when AndroidManifest.xml and build.gradle/build.gradle.kts exist for // the android dynamic feature. bool verifyFilesExist() { return androidManifestFile.existsSync() && buildGradleFile.existsSync(); } // Generates any missing basic files for the dynamic feature into a temporary directory. Future>> generateFiles({ Directory? alternateAndroidDir, bool clearAlternateOutputDir = false, }) async { final Directory outputDir = alternateAndroidDir?.childDirectory(name) ?? componentDir; if (clearAlternateOutputDir && alternateAndroidDir != null) { ErrorHandlingFileSystem.deleteIfExists(outputDir); } final inputs = []; inputs.add(androidManifestFile); inputs.add(buildGradleFile); final results = >{'inputs': inputs}; results['outputs'] = await _setupComponentFiles(outputDir); return results; } // generates default build.gradle and AndroidManifest.xml for the deferred component. Future> _setupComponentFiles(Directory outputDir) async { Template template; final Directory? templatesDir = _templatesDir; if (templatesDir != null) { final Directory templateComponentDir = templatesDir.childDirectory( 'module${globals.fs.path.separator}android${globals.fs.path.separator}deferred_component', ); template = Template( templateComponentDir, templateComponentDir, fileSystem: globals.fs, logger: logger, templateRenderer: globals.templateRenderer, ); } else { template = await Template.fromName( 'module${globals.fs.path.separator}android${globals.fs.path.separator}deferred_component', fileSystem: globals.fs, templateManifest: null, logger: logger, templateRenderer: globals.templateRenderer, ); } final context = { 'androidIdentifier': FlutterProject.current().manifest.androidPackage ?? 'com.example.${FlutterProject.current().manifest.appName}', 'componentName': name, }; template.render(outputDir, context); final generatedFiles = []; final File tempBuildGradle = outputDir.childFile('build.gradle'); if (!buildGradleFile.existsSync()) { generatedFiles.add(tempBuildGradle); } else { ErrorHandlingFileSystem.deleteIfExists(tempBuildGradle); } final File tempAndroidManifest = outputDir .childDirectory('src') .childDirectory('main') .childFile('AndroidManifest.xml'); if (!androidManifestFile.existsSync()) { generatedFiles.add(tempAndroidManifest); } else { ErrorHandlingFileSystem.deleteIfExists(tempAndroidManifest); } return generatedFiles; } }