| // 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 'dart:typed_data'; |
| |
| import 'package:meta/meta.dart'; |
| import 'package:package_config/package_config.dart'; |
| import 'package:standard_message_codec/standard_message_codec.dart'; |
| |
| import 'base/common.dart'; |
| import 'base/context.dart'; |
| import 'base/deferred_component.dart'; |
| import 'base/file_system.dart'; |
| import 'base/logger.dart'; |
| import 'base/platform.dart'; |
| import 'build_info.dart'; |
| import 'cache.dart'; |
| import 'convert.dart'; |
| import 'dart/package_map.dart'; |
| import 'devfs.dart'; |
| import 'flutter_manifest.dart'; |
| import 'license_collector.dart'; |
| import 'project.dart'; |
| |
| const String defaultManifestPath = 'pubspec.yaml'; |
| |
| const String kFontManifestJson = 'FontManifest.json'; |
| |
| // Should match '2x', '/1x', '1.5x', etc. |
| final RegExp _assetVariantDirectoryRegExp = RegExp(r'/?(\d+(\.\d*)?)x$'); |
| |
| /// The effect of adding `uses-material-design: true` to the pubspec is to insert |
| /// the following snippet into the asset manifest: |
| /// |
| /// ```yaml |
| /// material: |
| /// - family: MaterialIcons |
| /// fonts: |
| /// - asset: fonts/MaterialIcons-Regular.otf |
| /// ``` |
| const List<Map<String, Object>> kMaterialFonts = <Map<String, Object>>[ |
| <String, Object>{ |
| 'family': 'MaterialIcons', |
| 'fonts': <Map<String, String>>[ |
| <String, String>{ |
| 'asset': 'fonts/MaterialIcons-Regular.otf', |
| }, |
| ], |
| }, |
| ]; |
| |
| const List<String> kMaterialShaders = <String>[ |
| 'shaders/ink_sparkle.frag', |
| ]; |
| |
| /// Injected factory class for spawning [AssetBundle] instances. |
| abstract class AssetBundleFactory { |
| /// The singleton instance, pulled from the [AppContext]. |
| static AssetBundleFactory get instance => context.get<AssetBundleFactory>()!; |
| |
| static AssetBundleFactory defaultInstance({ |
| required Logger logger, |
| required FileSystem fileSystem, |
| required Platform platform, |
| bool splitDeferredAssets = false, |
| }) => _ManifestAssetBundleFactory(logger: logger, fileSystem: fileSystem, platform: platform, splitDeferredAssets: splitDeferredAssets); |
| |
| /// Creates a new [AssetBundle]. |
| AssetBundle createBundle(); |
| } |
| |
| enum AssetKind { |
| regular, |
| font, |
| shader, |
| model, |
| } |
| |
| abstract class AssetBundle { |
| Map<String, DevFSContent> get entries; |
| |
| Map<String, AssetKind> get entryKinds; |
| |
| /// The files that were specified under the deferred components assets sections |
| /// in pubspec. |
| Map<String, Map<String, DevFSContent>> get deferredComponentsEntries; |
| |
| /// Additional files that this bundle depends on that are not included in the |
| /// output result. |
| List<File> get additionalDependencies; |
| |
| /// Input files used to build this asset bundle. |
| List<File> get inputFiles; |
| |
| bool wasBuiltOnce(); |
| |
| bool needsBuild({ String manifestPath = defaultManifestPath }); |
| |
| /// Returns 0 for success; non-zero for failure. |
| Future<int> build({ |
| String manifestPath = defaultManifestPath, |
| required String packagesPath, |
| bool deferredComponentsEnabled = false, |
| TargetPlatform? targetPlatform, |
| String? flavor, |
| }); |
| } |
| |
| class _ManifestAssetBundleFactory implements AssetBundleFactory { |
| _ManifestAssetBundleFactory({ |
| required Logger logger, |
| required FileSystem fileSystem, |
| required Platform platform, |
| bool splitDeferredAssets = false, |
| }) : _logger = logger, |
| _fileSystem = fileSystem, |
| _platform = platform, |
| _splitDeferredAssets = splitDeferredAssets; |
| |
| final Logger _logger; |
| final FileSystem _fileSystem; |
| final Platform _platform; |
| final bool _splitDeferredAssets; |
| |
| @override |
| AssetBundle createBundle() => ManifestAssetBundle(logger: _logger, fileSystem: _fileSystem, platform: _platform, splitDeferredAssets: _splitDeferredAssets); |
| } |
| |
| /// An asset bundle based on a pubspec.yaml file. |
| class ManifestAssetBundle implements AssetBundle { |
| /// Constructs an [ManifestAssetBundle] that gathers the set of assets from the |
| /// pubspec.yaml manifest. |
| ManifestAssetBundle({ |
| required Logger logger, |
| required FileSystem fileSystem, |
| required Platform platform, |
| bool splitDeferredAssets = false, |
| }) : _logger = logger, |
| _fileSystem = fileSystem, |
| _platform = platform, |
| _splitDeferredAssets = splitDeferredAssets, |
| _licenseCollector = LicenseCollector(fileSystem: fileSystem); |
| |
| final Logger _logger; |
| final FileSystem _fileSystem; |
| final LicenseCollector _licenseCollector; |
| final Platform _platform; |
| final bool _splitDeferredAssets; |
| |
| @override |
| final Map<String, DevFSContent> entries = <String, DevFSContent>{}; |
| |
| @override |
| final Map<String, AssetKind> entryKinds = <String, AssetKind>{}; |
| |
| @override |
| final Map<String, Map<String, DevFSContent>> deferredComponentsEntries = <String, Map<String, DevFSContent>>{}; |
| |
| @override |
| final List<File> inputFiles = <File>[]; |
| |
| // If an asset corresponds to a wildcard directory, then it may have been |
| // updated without changes to the manifest. These are only tracked for |
| // the current project. |
| final Map<Uri, Directory> _wildcardDirectories = <Uri, Directory>{}; |
| |
| DateTime? _lastBuildTimestamp; |
| |
| // We assume the main asset is designed for a device pixel ratio of 1.0. |
| static const String _kAssetManifestJsonFilename = 'AssetManifest.json'; |
| static const String _kAssetManifestBinFilename = 'AssetManifest.bin'; |
| static const String _kAssetManifestBinJsonFilename = 'AssetManifest.bin.json'; |
| |
| static const String _kNoticeFile = 'NOTICES'; |
| // Comically, this can't be name with the more common .gz file extension |
| // because when it's part of an AAR and brought into another APK via gradle, |
| // gradle individually traverses all the files of the AAR and unzips .gz |
| // files (b/37117906). A less common .Z extension still describes how the |
| // file is formatted if users want to manually inspect the application |
| // bundle and is recognized by default file handlers on OS such as macOS.˚ |
| static const String _kNoticeZippedFile = 'NOTICES.Z'; |
| |
| @override |
| bool wasBuiltOnce() => _lastBuildTimestamp != null; |
| |
| @override |
| bool needsBuild({ String manifestPath = defaultManifestPath }) { |
| final DateTime? lastBuildTimestamp = _lastBuildTimestamp; |
| if (lastBuildTimestamp == null) { |
| return true; |
| } |
| |
| final FileStat stat = _fileSystem.file(manifestPath).statSync(); |
| if (stat.type == FileSystemEntityType.notFound) { |
| return true; |
| } |
| |
| for (final Directory directory in _wildcardDirectories.values) { |
| if (!directory.existsSync()) { |
| return true; // directory was deleted. |
| } |
| for (final File file in directory.listSync().whereType<File>()) { |
| final DateTime dateTime = file.statSync().modified; |
| if (dateTime.isAfter(lastBuildTimestamp)) { |
| return true; |
| } |
| } |
| } |
| |
| return stat.modified.isAfter(lastBuildTimestamp); |
| } |
| |
| @override |
| Future<int> build({ |
| String manifestPath = defaultManifestPath, |
| FlutterProject? flutterProject, |
| required String packagesPath, |
| bool deferredComponentsEnabled = false, |
| TargetPlatform? targetPlatform, |
| String? flavor, |
| }) async { |
| if (flutterProject == null) { |
| try { |
| flutterProject = FlutterProject.fromDirectory(_fileSystem.file(manifestPath).parent); |
| } on Exception catch (e) { |
| _logger.printStatus('Error detected in pubspec.yaml:', emphasis: true); |
| _logger.printError('$e'); |
| return 1; |
| } |
| } |
| |
| final FlutterManifest flutterManifest = flutterProject.manifest; |
| // If the last build time isn't set before this early return, empty pubspecs will |
| // hang on hot reload, as the incremental dill files will never be copied to the |
| // device. |
| _lastBuildTimestamp = DateTime.now(); |
| if (flutterManifest.isEmpty) { |
| entries[_kAssetManifestJsonFilename] = DevFSStringContent('{}'); |
| entryKinds[_kAssetManifestJsonFilename] = AssetKind.regular; |
| final ByteData emptyAssetManifest = |
| const StandardMessageCodec().encodeMessage(<dynamic, dynamic>{})!; |
| entries[_kAssetManifestBinFilename] = |
| DevFSByteContent(emptyAssetManifest.buffer.asUint8List(0, emptyAssetManifest.lengthInBytes)); |
| entryKinds[_kAssetManifestBinFilename] = AssetKind.regular; |
| // Create .bin.json on web builds. |
| if (targetPlatform == TargetPlatform.web_javascript) { |
| entries[_kAssetManifestBinJsonFilename] = DevFSStringContent('""'); |
| entryKinds[_kAssetManifestBinJsonFilename] = AssetKind.regular; |
| } |
| return 0; |
| } |
| |
| final String assetBasePath = _fileSystem.path.dirname(_fileSystem.path.absolute(manifestPath)); |
| final File packageConfigFile = _fileSystem.file(packagesPath); |
| inputFiles.add(packageConfigFile); |
| final PackageConfig packageConfig = await loadPackageConfigWithLogging( |
| packageConfigFile, |
| logger: _logger, |
| ); |
| final List<Uri> wildcardDirectories = <Uri>[]; |
| |
| // The _assetVariants map contains an entry for each asset listed |
| // in the pubspec.yaml file's assets and font sections. The |
| // value of each image asset is a list of resolution-specific "variants", |
| // see _AssetDirectoryCache. |
| final Map<_Asset, List<_Asset>>? assetVariants = _parseAssets( |
| packageConfig, |
| flutterManifest, |
| wildcardDirectories, |
| assetBasePath, |
| targetPlatform, |
| flavor: flavor, |
| ); |
| |
| if (assetVariants == null) { |
| return 1; |
| } |
| |
| // Parse assets for deferred components. |
| final Map<String, Map<_Asset, List<_Asset>>> deferredComponentsAssetVariants = _parseDeferredComponentsAssets( |
| flutterManifest, |
| packageConfig, |
| assetBasePath, |
| wildcardDirectories, |
| flutterProject.directory, |
| flavor: flavor, |
| ); |
| if (!_splitDeferredAssets || !deferredComponentsEnabled) { |
| // Include the assets in the regular set of assets if not using deferred |
| // components. |
| deferredComponentsAssetVariants.values.forEach(assetVariants.addAll); |
| deferredComponentsAssetVariants.clear(); |
| deferredComponentsEntries.clear(); |
| } |
| |
| final bool includesMaterialFonts = flutterManifest.usesMaterialDesign; |
| final List<Map<String, Object?>> fonts = _parseFonts( |
| flutterManifest, |
| packageConfig, |
| primary: true, |
| ); |
| |
| // Add fonts, assets, and licenses from packages. |
| final Map<String, List<File>> additionalLicenseFiles = <String, List<File>>{}; |
| for (final Package package in packageConfig.packages) { |
| final Uri packageUri = package.packageUriRoot; |
| if (packageUri.scheme == 'file') { |
| final String packageManifestPath = _fileSystem.path.fromUri(packageUri.resolve('../pubspec.yaml')); |
| inputFiles.add(_fileSystem.file(packageManifestPath)); |
| final FlutterManifest? packageFlutterManifest = FlutterManifest.createFromPath( |
| packageManifestPath, |
| logger: _logger, |
| fileSystem: _fileSystem, |
| ); |
| if (packageFlutterManifest == null) { |
| continue; |
| } |
| // Collect any additional licenses from each package. |
| final List<File> licenseFiles = <File>[]; |
| for (final String relativeLicensePath in packageFlutterManifest.additionalLicenses) { |
| final String absoluteLicensePath = _fileSystem.path.fromUri(package.root.resolve(relativeLicensePath)); |
| licenseFiles.add(_fileSystem.file(absoluteLicensePath).absolute); |
| } |
| additionalLicenseFiles[packageFlutterManifest.appName] = licenseFiles; |
| |
| // Skip the app itself |
| if (packageFlutterManifest.appName == flutterManifest.appName) { |
| continue; |
| } |
| final String packageBasePath = _fileSystem.path.dirname(packageManifestPath); |
| |
| final Map<_Asset, List<_Asset>>? packageAssets = _parseAssets( |
| packageConfig, |
| packageFlutterManifest, |
| // Do not track wildcard directories for dependencies. |
| <Uri>[], |
| packageBasePath, |
| targetPlatform, |
| packageName: package.name, |
| attributedPackage: package, |
| ); |
| |
| if (packageAssets == null) { |
| return 1; |
| } |
| assetVariants.addAll(packageAssets); |
| if (!includesMaterialFonts && packageFlutterManifest.usesMaterialDesign) { |
| _logger.printError( |
| 'package:${package.name} has `uses-material-design: true` set but ' |
| 'the primary pubspec contains `uses-material-design: false`. ' |
| 'If the application needs material icons, then `uses-material-design` ' |
| ' must be set to true.' |
| ); |
| } |
| fonts.addAll(_parseFonts( |
| packageFlutterManifest, |
| packageConfig, |
| packageName: package.name, |
| primary: false, |
| )); |
| } |
| } |
| |
| // Save the contents of each image, image variant, and font |
| // asset in entries. |
| for (final _Asset asset in assetVariants.keys) { |
| final File assetFile = asset.lookupAssetFile(_fileSystem); |
| final List<_Asset> variants = assetVariants[asset]!; |
| if (!assetFile.existsSync() && variants.isEmpty) { |
| _logger.printStatus('Error detected in pubspec.yaml:', emphasis: true); |
| _logger.printError('No file or variants found for $asset.\n'); |
| if (asset.package != null) { |
| _logger.printError('This asset was included from package ${asset.package?.name}.'); |
| } |
| return 1; |
| } |
| // The file name for an asset's "main" entry is whatever appears in |
| // the pubspec.yaml file. The main entry's file must always exist for |
| // font assets. It need not exist for an image if resolution-specific |
| // variant files exist. An image's main entry is treated the same as a |
| // "1x" resolution variant and if both exist then the explicit 1x |
| // variant is preferred. |
| if (assetFile.existsSync() && !variants.contains(asset)) { |
| variants.insert(0, asset); |
| } |
| for (final _Asset variant in variants) { |
| final File variantFile = variant.lookupAssetFile(_fileSystem); |
| inputFiles.add(variantFile); |
| assert(variantFile.existsSync()); |
| entries[variant.entryUri.path] ??= DevFSFileContent(variantFile); |
| entryKinds[variant.entryUri.path] ??= variant.assetKind; |
| } |
| } |
| // Save the contents of each deferred component image, image variant, and font |
| // asset in deferredComponentsEntries. |
| for (final String componentName in deferredComponentsAssetVariants.keys) { |
| deferredComponentsEntries[componentName] = <String, DevFSContent>{}; |
| final Map<_Asset, List<_Asset>> assetsMap = deferredComponentsAssetVariants[componentName]!; |
| for (final _Asset asset in assetsMap.keys) { |
| final File assetFile = asset.lookupAssetFile(_fileSystem); |
| if (!assetFile.existsSync() && assetsMap[asset]!.isEmpty) { |
| _logger.printStatus('Error detected in pubspec.yaml:', emphasis: true); |
| _logger.printError('No file or variants found for $asset.\n'); |
| if (asset.package != null) { |
| _logger.printError('This asset was included from package ${asset.package?.name}.'); |
| } |
| return 1; |
| } |
| // The file name for an asset's "main" entry is whatever appears in |
| // the pubspec.yaml file. The main entry's file must always exist for |
| // font assets. It need not exist for an image if resolution-specific |
| // variant files exist. An image's main entry is treated the same as a |
| // "1x" resolution variant and if both exist then the explicit 1x |
| // variant is preferred. |
| if (assetFile.existsSync() && !assetsMap[asset]!.contains(asset)) { |
| assetsMap[asset]!.insert(0, asset); |
| } |
| for (final _Asset variant in assetsMap[asset]!) { |
| final File variantFile = variant.lookupAssetFile(_fileSystem); |
| assert(variantFile.existsSync()); |
| deferredComponentsEntries[componentName]![variant.entryUri.path] ??= DevFSFileContent(variantFile); |
| } |
| } |
| } |
| final List<_Asset> materialAssets = <_Asset>[ |
| if (flutterManifest.usesMaterialDesign) |
| ..._getMaterialFonts(), |
| // For all platforms, include the shaders unconditionally. They are |
| // small, and whether they're used is determined only by the app source |
| // code and not by the Flutter manifest. |
| ..._getMaterialShaders(), |
| ]; |
| for (final _Asset asset in materialAssets) { |
| final File assetFile = asset.lookupAssetFile(_fileSystem); |
| assert(assetFile.existsSync(), 'Missing ${assetFile.path}'); |
| entries[asset.entryUri.path] ??= DevFSFileContent(assetFile); |
| entryKinds[asset.entryUri.path] ??= asset.assetKind; |
| } |
| |
| // Update wildcard directories we can detect changes in them. |
| for (final Uri uri in wildcardDirectories) { |
| _wildcardDirectories[uri] ??= _fileSystem.directory(uri); |
| } |
| |
| final Map<String, List<String>> assetManifest = |
| _createAssetManifest(assetVariants, deferredComponentsAssetVariants); |
| final DevFSByteContent assetManifestBinary = _createAssetManifestBinary(assetManifest); |
| final DevFSStringContent assetManifestJson = DevFSStringContent(json.encode(assetManifest)); |
| final DevFSStringContent fontManifest = DevFSStringContent(json.encode(fonts)); |
| final LicenseResult licenseResult = _licenseCollector.obtainLicenses(packageConfig, additionalLicenseFiles); |
| if (licenseResult.errorMessages.isNotEmpty) { |
| licenseResult.errorMessages.forEach(_logger.printError); |
| return 1; |
| } |
| |
| additionalDependencies = licenseResult.dependencies; |
| inputFiles.addAll(additionalDependencies); |
| |
| if (wildcardDirectories.isNotEmpty) { |
| // Force the depfile to contain missing files so that Gradle does not skip |
| // the task. Wildcard directories are not compatible with full incremental |
| // builds. For more context see https://github.com/flutter/flutter/issues/56466 . |
| _logger.printTrace( |
| 'Manifest contained wildcard assets. Inserting missing file into ' |
| 'build graph to force rerun. for more information see #56466.' |
| ); |
| final int suffix = Object().hashCode; |
| additionalDependencies.add( |
| _fileSystem.file('DOES_NOT_EXIST_RERUN_FOR_WILDCARD$suffix').absolute); |
| } |
| |
| _setIfChanged(_kAssetManifestJsonFilename, assetManifestJson, AssetKind.regular); |
| _setIfChanged(_kAssetManifestBinFilename, assetManifestBinary, AssetKind.regular); |
| // Create .bin.json on web builds. |
| if (targetPlatform == TargetPlatform.web_javascript) { |
| final DevFSStringContent assetManifestBinaryJson = DevFSStringContent(json.encode( |
| base64.encode(assetManifestBinary.bytes) |
| )); |
| _setIfChanged(_kAssetManifestBinJsonFilename, assetManifestBinaryJson, AssetKind.regular); |
| } |
| _setIfChanged(kFontManifestJson, fontManifest, AssetKind.regular); |
| _setLicenseIfChanged(licenseResult.combinedLicenses, targetPlatform); |
| return 0; |
| } |
| |
| @override |
| List<File> additionalDependencies = <File>[]; |
| void _setIfChanged(String key, DevFSContent content, AssetKind assetKind) { |
| final DevFSContent? oldContent = entries[key]; |
| // In the case that the content is unchanged, we want to avoid an overwrite |
| // as the isModified property may be reset to true, |
| if (oldContent is DevFSByteContent && content is DevFSByteContent && |
| _compareIntLists(oldContent.bytes, content.bytes)) { |
| return; |
| } |
| |
| entries[key] = content; |
| entryKinds[key] = assetKind; |
| } |
| |
| static bool _compareIntLists(List<int> o1, List<int> o2) { |
| if (o1.length != o2.length) { |
| return false; |
| } |
| |
| for (int index = 0; index < o1.length; index++) { |
| if (o1[index] != o2[index]) { |
| return false; |
| } |
| } |
| |
| return true; |
| } |
| |
| void _setLicenseIfChanged( |
| String combinedLicenses, |
| TargetPlatform? targetPlatform, |
| ) { |
| // On the web, don't compress the NOTICES file since the client doesn't have |
| // dart:io to decompress it. So use the standard _setIfChanged to check if |
| // the strings still match. |
| if (targetPlatform == TargetPlatform.web_javascript) { |
| _setIfChanged(_kNoticeFile, DevFSStringContent(combinedLicenses), AssetKind.regular); |
| return; |
| } |
| |
| // On other platforms, let the NOTICES file be compressed. But use a |
| // specialized DevFSStringCompressingBytesContent class to compare |
| // the uncompressed strings to not incur decompression/decoding while making |
| // the comparison. |
| if (!entries.containsKey(_kNoticeZippedFile) || |
| (entries[_kNoticeZippedFile] as DevFSStringCompressingBytesContent?) |
| ?.equals(combinedLicenses) != true) { |
| entries[_kNoticeZippedFile] = DevFSStringCompressingBytesContent( |
| combinedLicenses, |
| // A zlib dictionary is a hinting string sequence with the most |
| // likely string occurrences at the end. This ends up just being |
| // common English words with domain specific words like copyright. |
| hintString: 'copyrightsoftwaretothisinandorofthe', |
| ); |
| entryKinds[_kNoticeZippedFile] = AssetKind.regular; |
| } |
| } |
| |
| List<_Asset> _getMaterialFonts() { |
| final List<_Asset> result = <_Asset>[]; |
| for (final Map<String, Object> family in kMaterialFonts) { |
| final Object? fonts = family['fonts']; |
| if (fonts == null) { |
| continue; |
| } |
| for (final Map<String, Object> font in fonts as List<Map<String, String>>) { |
| final String? asset = font['asset'] as String?; |
| if (asset == null) { |
| continue; |
| } |
| final Uri entryUri = _fileSystem.path.toUri(asset); |
| result.add(_Asset( |
| baseDir: _fileSystem.path.join( |
| Cache.flutterRoot!, |
| 'bin', 'cache', 'artifacts', 'material_fonts', |
| ), |
| relativeUri: Uri(path: entryUri.pathSegments.last), |
| entryUri: entryUri, |
| package: null, |
| assetKind: AssetKind.font, |
| )); |
| } |
| } |
| |
| return result; |
| } |
| |
| List<_Asset> _getMaterialShaders() { |
| final String shaderPath = _fileSystem.path.join( |
| Cache.flutterRoot!, |
| 'packages', 'flutter', 'lib', 'src', 'material', 'shaders', |
| ); |
| // This file will exist in a real invocation unless the git checkout is |
| // corrupted somehow, but unit tests generally don't create this file |
| // in their mock file systems. Leaving it out in those cases is harmless. |
| if (!_fileSystem.directory(shaderPath).existsSync()) { |
| return <_Asset>[]; |
| } |
| |
| final List<_Asset> result = <_Asset>[]; |
| for (final String shader in kMaterialShaders) { |
| final Uri entryUri = _fileSystem.path.toUri(shader); |
| result.add(_Asset( |
| baseDir: shaderPath, |
| relativeUri: Uri(path: entryUri.pathSegments.last), |
| entryUri: entryUri, |
| package: null, |
| assetKind: AssetKind.shader, |
| )); |
| } |
| |
| return result; |
| } |
| |
| List<Map<String, Object?>> _parseFonts( |
| FlutterManifest manifest, |
| PackageConfig packageConfig, { |
| String? packageName, |
| required bool primary, |
| }) { |
| return <Map<String, Object?>>[ |
| if (primary && manifest.usesMaterialDesign) |
| ...kMaterialFonts, |
| if (packageName == null) |
| ...manifest.fontsDescriptor |
| else |
| for (final Font font in _parsePackageFonts( |
| manifest, |
| packageName, |
| packageConfig, |
| )) font.descriptor, |
| ]; |
| } |
| |
| Map<String, Map<_Asset, List<_Asset>>> _parseDeferredComponentsAssets( |
| FlutterManifest flutterManifest, |
| PackageConfig packageConfig, |
| String assetBasePath, |
| List<Uri> wildcardDirectories, |
| Directory projectDirectory, { |
| String? flavor, |
| }) { |
| final List<DeferredComponent>? components = flutterManifest.deferredComponents; |
| final Map<String, Map<_Asset, List<_Asset>>> deferredComponentsAssetVariants = <String, Map<_Asset, List<_Asset>>>{}; |
| if (components == null) { |
| return deferredComponentsAssetVariants; |
| } |
| for (final DeferredComponent component in components) { |
| final _AssetDirectoryCache cache = _AssetDirectoryCache(_fileSystem); |
| final Map<_Asset, List<_Asset>> componentAssets = <_Asset, List<_Asset>>{}; |
| for (final AssetsEntry assetsEntry in component.assets) { |
| if (assetsEntry.uri.path.endsWith('/')) { |
| wildcardDirectories.add(assetsEntry.uri); |
| _parseAssetsFromFolder( |
| packageConfig, |
| flutterManifest, |
| assetBasePath, |
| cache, |
| componentAssets, |
| assetsEntry.uri, |
| ); |
| } else { |
| _parseAssetFromFile( |
| packageConfig, |
| flutterManifest, |
| assetBasePath, |
| cache, |
| componentAssets, |
| assetsEntry.uri, |
| ); |
| } |
| } |
| |
| componentAssets.removeWhere((_Asset asset, List<_Asset> variants) => !asset.matchesFlavor(flavor)); |
| deferredComponentsAssetVariants[component.name] = componentAssets; |
| } |
| return deferredComponentsAssetVariants; |
| } |
| |
| Map<String, List<String>> _createAssetManifest( |
| Map<_Asset, List<_Asset>> assetVariants, |
| Map<String, Map<_Asset, List<_Asset>>> deferredComponentsAssetVariants |
| ) { |
| final Map<String, List<String>> manifest = <String, List<String>>{}; |
| final Map<_Asset, List<String>> entries = <_Asset, List<String>>{}; |
| assetVariants.forEach((_Asset main, List<_Asset> variants) { |
| entries[main] = <String>[ |
| for (final _Asset variant in variants) |
| variant.entryUri.path, |
| ]; |
| }); |
| for (final Map<_Asset, List<_Asset>> componentAssets in deferredComponentsAssetVariants.values) { |
| componentAssets.forEach((_Asset main, List<_Asset> variants) { |
| entries[main] = <String>[ |
| for (final _Asset variant in variants) |
| variant.entryUri.path, |
| ]; |
| }); |
| } |
| final List<_Asset> sortedKeys = entries.keys.toList() |
| ..sort((_Asset left, _Asset right) => left.entryUri.path.compareTo(right.entryUri.path)); |
| for (final _Asset main in sortedKeys) { |
| final String decodedEntryPath = Uri.decodeFull(main.entryUri.path); |
| final List<String> rawEntryVariantsPaths = entries[main]!; |
| final List<String> decodedEntryVariantPaths = rawEntryVariantsPaths |
| .map((String value) => Uri.decodeFull(value)) |
| .toList(); |
| manifest[decodedEntryPath] = decodedEntryVariantPaths; |
| } |
| return manifest; |
| } |
| |
| // Matches path-like strings ending in a number followed by an 'x'. |
| // Example matches include "assets/animals/2.0x", "plants/3x", and "2.7x". |
| static final RegExp _extractPixelRatioFromKeyRegExp = RegExp(r'/?(\d+(\.\d*)?)x$'); |
| |
| DevFSByteContent _createAssetManifestBinary( |
| Map<String, List<String>> assetManifest |
| ) { |
| double? parseScale(String key) { |
| final Uri assetUri = Uri.parse(key); |
| String directoryPath = ''; |
| if (assetUri.pathSegments.length > 1) { |
| directoryPath = assetUri.pathSegments[assetUri.pathSegments.length - 2]; |
| } |
| |
| final Match? match = _extractPixelRatioFromKeyRegExp.firstMatch(directoryPath); |
| if (match != null && match.groupCount > 0) { |
| return double.parse(match.group(1)!); |
| } |
| |
| return null; |
| } |
| |
| final Map<String, dynamic> result = <String, dynamic>{}; |
| |
| for (final MapEntry<String, dynamic> manifestEntry in assetManifest.entries) { |
| final List<dynamic> resultVariants = <dynamic>[]; |
| final List<String> entries = (manifestEntry.value as List<dynamic>).cast<String>(); |
| for (final String variant in entries) { |
| final Map<String, dynamic> resultVariant = <String, dynamic>{}; |
| final double? variantDevicePixelRatio = parseScale(variant); |
| resultVariant['asset'] = variant; |
| if (variantDevicePixelRatio != null) { |
| resultVariant['dpr'] = variantDevicePixelRatio; |
| } |
| resultVariants.add(resultVariant); |
| } |
| result[manifestEntry.key] = resultVariants; |
| } |
| |
| final ByteData message = const StandardMessageCodec().encodeMessage(result)!; |
| return DevFSByteContent(message.buffer.asUint8List(0, message.lengthInBytes)); |
| } |
| |
| /// Prefixes family names and asset paths of fonts included from packages with |
| /// 'packages/<package_name>' |
| List<Font> _parsePackageFonts( |
| FlutterManifest manifest, |
| String packageName, |
| PackageConfig packageConfig, |
| ) { |
| final List<Font> packageFonts = <Font>[]; |
| for (final Font font in manifest.fonts) { |
| final List<FontAsset> packageFontAssets = <FontAsset>[]; |
| for (final FontAsset fontAsset in font.fontAssets) { |
| final Uri assetUri = fontAsset.assetUri; |
| if (assetUri.pathSegments.first == 'packages' && |
| !_fileSystem.isFileSync(_fileSystem.path.fromUri( |
| packageConfig[packageName]?.packageUriRoot.resolve('../${assetUri.path}')))) { |
| packageFontAssets.add(FontAsset( |
| fontAsset.assetUri, |
| weight: fontAsset.weight, |
| style: fontAsset.style, |
| )); |
| } else { |
| packageFontAssets.add(FontAsset( |
| Uri(pathSegments: <String>['packages', packageName, ...assetUri.pathSegments]), |
| weight: fontAsset.weight, |
| style: fontAsset.style, |
| )); |
| } |
| } |
| packageFonts.add(Font('packages/$packageName/${font.familyName}', packageFontAssets)); |
| } |
| return packageFonts; |
| } |
| |
| /// Given an assetBase location and a pubspec.yaml Flutter manifest, return a |
| /// map of assets to asset variants. |
| /// |
| /// Returns null on missing assets. |
| /// |
| /// Given package: 'test_package' and an assets directory like this: |
| /// |
| /// - assets/foo |
| /// - assets/var1/foo |
| /// - assets/var2/foo |
| /// - assets/bar |
| /// |
| /// This will return: |
| /// ``` |
| /// { |
| /// asset: packages/test_package/assets/foo: [ |
| /// asset: packages/test_package/assets/foo, |
| /// asset: packages/test_package/assets/var1/foo, |
| /// asset: packages/test_package/assets/var2/foo, |
| /// ], |
| /// asset: packages/test_package/assets/bar: [ |
| /// asset: packages/test_package/assets/bar, |
| /// ], |
| /// } |
| /// ``` |
| Map<_Asset, List<_Asset>>? _parseAssets( |
| PackageConfig packageConfig, |
| FlutterManifest flutterManifest, |
| List<Uri> wildcardDirectories, |
| String assetBase, |
| TargetPlatform? targetPlatform, { |
| String? packageName, |
| Package? attributedPackage, |
| String? flavor, |
| }) { |
| final Map<_Asset, List<_Asset>> result = <_Asset, List<_Asset>>{}; |
| |
| final _AssetDirectoryCache cache = _AssetDirectoryCache(_fileSystem); |
| for (final AssetsEntry assetsEntry in flutterManifest.assets) { |
| if (assetsEntry.uri.path.endsWith('/')) { |
| wildcardDirectories.add(assetsEntry.uri); |
| _parseAssetsFromFolder( |
| packageConfig, |
| flutterManifest, |
| assetBase, |
| cache, |
| result, |
| assetsEntry.uri, |
| packageName: packageName, |
| attributedPackage: attributedPackage, |
| flavors: assetsEntry.flavors, |
| ); |
| } else { |
| _parseAssetFromFile( |
| packageConfig, |
| flutterManifest, |
| assetBase, |
| cache, |
| result, |
| assetsEntry.uri, |
| packageName: packageName, |
| attributedPackage: attributedPackage, |
| flavors: assetsEntry.flavors, |
| ); |
| } |
| } |
| |
| result.removeWhere((_Asset asset, List<_Asset> variants) { |
| if (!asset.matchesFlavor(flavor)) { |
| _logger.printTrace('Skipping assets entry "${asset.entryUri.path}" since ' |
| 'its configured flavor(s) did not match the provided flavor (if any).\n' |
| 'Configured flavors: ${asset.flavors.join(', ')}\n'); |
| return true; |
| } |
| return false; |
| }); |
| |
| for (final Uri shaderUri in flutterManifest.shaders) { |
| _parseAssetFromFile( |
| packageConfig, |
| flutterManifest, |
| assetBase, |
| cache, |
| result, |
| shaderUri, |
| packageName: packageName, |
| attributedPackage: attributedPackage, |
| assetKind: AssetKind.shader, |
| ); |
| } |
| |
| for (final Uri modelUri in flutterManifest.models) { |
| _parseAssetFromFile( |
| packageConfig, |
| flutterManifest, |
| assetBase, |
| cache, |
| result, |
| modelUri, |
| packageName: packageName, |
| attributedPackage: attributedPackage, |
| assetKind: AssetKind.model, |
| ); |
| } |
| |
| // Add assets referenced in the fonts section of the manifest. |
| for (final Font font in flutterManifest.fonts) { |
| for (final FontAsset fontAsset in font.fontAssets) { |
| final _Asset baseAsset = _resolveAsset( |
| packageConfig, |
| assetBase, |
| fontAsset.assetUri, |
| packageName, |
| attributedPackage, |
| assetKind: AssetKind.font, |
| ); |
| final File baseAssetFile = baseAsset.lookupAssetFile(_fileSystem); |
| if (!baseAssetFile.existsSync()) { |
| _logger.printError('Error: unable to locate asset entry in pubspec.yaml: "${fontAsset.assetUri}".'); |
| return null; |
| } |
| result[baseAsset] = <_Asset>[]; |
| } |
| } |
| |
| return result; |
| } |
| |
| void _parseAssetsFromFolder( |
| PackageConfig packageConfig, |
| FlutterManifest flutterManifest, |
| String assetBase, |
| _AssetDirectoryCache cache, |
| Map<_Asset, List<_Asset>> result, |
| Uri assetUri, { |
| String? packageName, |
| Package? attributedPackage, |
| List<String>? flavors, |
| }) { |
| final String directoryPath = _fileSystem.path.join( |
| assetBase, assetUri.toFilePath(windows: _platform.isWindows)); |
| |
| if (!_fileSystem.directory(directoryPath).existsSync()) { |
| _logger.printError('Error: unable to find directory entry in pubspec.yaml: $directoryPath'); |
| return; |
| } |
| |
| final Iterable<FileSystemEntity> entities = _fileSystem.directory(directoryPath).listSync(); |
| |
| final Iterable<File> files = entities.whereType<File>(); |
| for (final File file in files) { |
| final String relativePath = _fileSystem.path.relative(file.path, from: assetBase); |
| final Uri uri = Uri.file(relativePath, windows: _platform.isWindows); |
| |
| _parseAssetFromFile( |
| packageConfig, |
| flutterManifest, |
| assetBase, |
| cache, |
| result, |
| uri, |
| packageName: packageName, |
| attributedPackage: attributedPackage, |
| originUri: assetUri, |
| flavors: flavors, |
| ); |
| } |
| } |
| |
| void _parseAssetFromFile( |
| PackageConfig packageConfig, |
| FlutterManifest flutterManifest, |
| String assetBase, |
| _AssetDirectoryCache cache, |
| Map<_Asset, List<_Asset>> result, |
| Uri assetUri, { |
| Uri? originUri, |
| String? packageName, |
| Package? attributedPackage, |
| AssetKind assetKind = AssetKind.regular, |
| List<String>? flavors, |
| }) { |
| final _Asset asset = _resolveAsset( |
| packageConfig, |
| assetBase, |
| assetUri, |
| packageName, |
| attributedPackage, |
| assetKind: assetKind, |
| originUri: originUri, |
| flavors: flavors, |
| ); |
| |
| _checkForFlavorConflicts(asset, result.keys.toList()); |
| |
| final List<_Asset> variants = <_Asset>[]; |
| final File assetFile = asset.lookupAssetFile(_fileSystem); |
| |
| for (final String path in cache.variantsFor(assetFile.path)) { |
| final String relativePath = _fileSystem.path.relative(path, from: asset.baseDir); |
| final Uri relativeUri = _fileSystem.path.toUri(relativePath); |
| final Uri? entryUri = asset.symbolicPrefixUri == null |
| ? relativeUri |
| : asset.symbolicPrefixUri?.resolveUri(relativeUri); |
| if (entryUri != null) { |
| variants.add( |
| _Asset( |
| baseDir: asset.baseDir, |
| entryUri: entryUri, |
| relativeUri: relativeUri, |
| package: attributedPackage, |
| assetKind: assetKind, |
| ), |
| ); |
| } |
| } |
| |
| result[asset] = variants; |
| } |
| |
| // Since it is not clear how overlapping asset declarations should work in the |
| // presence of conditions such as `flavor`, we throw an Error. |
| // |
| // To be more specific, it is not clear if conditions should be combined with |
| // or-logic or and-logic, or if it should depend on the specificity of the |
| // declarations (file versus directory). If you would like examples, consider these: |
| // |
| // ```yaml |
| // # Should assets/free.mp3 always be included since "assets/" has no flavor? |
| // assets: |
| // - assets/ |
| // - path: assets/free.mp3 |
| // flavor: free |
| // |
| // # Should "assets/paid/pip.mp3" be included for both the "paid" and "free" flavors? |
| // # Or, since "assets/paid/pip.mp3" is more specific than "assets/paid/"", should |
| // # it take precedence over the latter (included only in "free" flavor)? |
| // assets: |
| // - path: assets/paid/ |
| // flavor: paid |
| // - path: assets/paid/pip.mp3 |
| // flavor: free |
| // - asset |
| // ``` |
| // |
| // Since it is not obvious what logic (if any) would be intuitive and preferable |
| // to the vast majority of users (if any), we play it safe by throwing a `ToolExit` |
| // in any of these situations. We can always loosen up this restriction later |
| // without breaking anyone. |
| void _checkForFlavorConflicts(_Asset newAsset, List<_Asset> previouslyParsedAssets) { |
| bool cameFromDirectoryEntry(_Asset asset) { |
| return asset.originUri.path.endsWith('/'); |
| } |
| |
| String flavorErrorInfo(_Asset asset) { |
| if (asset.flavors.isEmpty) { |
| return 'An entry with the path "${asset.originUri}" does not specify any flavors.'; |
| } |
| |
| final Iterable<String> flavorsWrappedWithQuotes = asset.flavors.map((String e) => '"$e"'); |
| return 'An entry with the path "${asset.originUri}" specifies the flavor(s): ' |
| '${flavorsWrappedWithQuotes.join(', ')}.'; |
| } |
| |
| final _Asset? preExistingAsset = previouslyParsedAssets |
| .where((_Asset other) => other.entryUri == newAsset.entryUri) |
| .firstOrNull; |
| |
| if (preExistingAsset == null || preExistingAsset.hasEquivalentFlavorsWith(newAsset)) { |
| return; |
| } |
| |
| final StringBuffer errorMessage = StringBuffer( |
| 'Multiple assets entries include the file ' |
| '"${newAsset.entryUri.path}", but they specify different lists of flavors.\n'); |
| |
| errorMessage.writeln(flavorErrorInfo(preExistingAsset)); |
| errorMessage.writeln(flavorErrorInfo(newAsset)); |
| |
| if (cameFromDirectoryEntry(newAsset)|| cameFromDirectoryEntry(preExistingAsset)) { |
| errorMessage.writeln(); |
| errorMessage.write('Consider organizing assets with different flavors ' |
| 'into different directories.'); |
| } |
| |
| throwToolExit(errorMessage.toString()); |
| } |
| |
| _Asset _resolveAsset( |
| PackageConfig packageConfig, |
| String assetsBaseDir, |
| Uri assetUri, |
| String? packageName, |
| Package? attributedPackage, { |
| Uri? originUri, |
| AssetKind assetKind = AssetKind.regular, |
| List<String>? flavors, |
| }) { |
| final String assetPath = _fileSystem.path.fromUri(assetUri); |
| if (assetUri.pathSegments.first == 'packages' |
| && !_fileSystem.isFileSync(_fileSystem.path.join(assetsBaseDir, assetPath))) { |
| // The asset is referenced in the pubspec.yaml as |
| // 'packages/PACKAGE_NAME/PATH/TO/ASSET . |
| final _Asset? packageAsset = _resolvePackageAsset( |
| assetUri, |
| packageConfig, |
| attributedPackage, |
| assetKind: assetKind, |
| originUri: originUri, |
| flavors: flavors, |
| ); |
| if (packageAsset != null) { |
| return packageAsset; |
| } |
| } |
| |
| return _Asset( |
| baseDir: assetsBaseDir, |
| entryUri: packageName == null |
| ? assetUri // Asset from the current application. |
| : Uri(pathSegments: <String>['packages', packageName, ...assetUri.pathSegments]), // Asset from, and declared in $packageName. |
| relativeUri: assetUri, |
| package: attributedPackage, |
| originUri: originUri, |
| assetKind: assetKind, |
| flavors: flavors, |
| ); |
| } |
| |
| _Asset? _resolvePackageAsset( |
| Uri assetUri, |
| PackageConfig packageConfig, |
| Package? attributedPackage, { |
| AssetKind assetKind = AssetKind.regular, |
| Uri? originUri, |
| List<String>? flavors, |
| }) { |
| assert(assetUri.pathSegments.first == 'packages'); |
| if (assetUri.pathSegments.length > 1) { |
| final String packageName = assetUri.pathSegments[1]; |
| final Package? package = packageConfig[packageName]; |
| final Uri? packageUri = package?.packageUriRoot; |
| if (packageUri != null && packageUri.scheme == 'file') { |
| return _Asset( |
| baseDir: _fileSystem.path.fromUri(packageUri), |
| entryUri: assetUri, |
| relativeUri: Uri(pathSegments: assetUri.pathSegments.sublist(2)), |
| package: attributedPackage, |
| assetKind: assetKind, |
| originUri: originUri, |
| flavors: flavors, |
| ); |
| } |
| } |
| _logger.printStatus('Error detected in pubspec.yaml:', emphasis: true); |
| _logger.printError('Could not resolve package for asset $assetUri.\n'); |
| if (attributedPackage != null) { |
| _logger.printError('This asset was included from package ${attributedPackage.name}'); |
| } |
| return null; |
| } |
| } |
| |
| @immutable |
| class _Asset { |
| const _Asset({ |
| required this.baseDir, |
| Uri? originUri, |
| required this.relativeUri, |
| required this.entryUri, |
| required this.package, |
| this.assetKind = AssetKind.regular, |
| List<String>? flavors, |
| }): originUri = originUri ?? entryUri, flavors = flavors ?? const <String>[]; |
| |
| final String baseDir; |
| |
| final Package? package; |
| |
| /// The platform-independent URL provided by the user in the pubspec that this |
| /// asset was found from. |
| final Uri originUri; |
| |
| /// A platform-independent URL where this asset can be found on disk on the |
| /// host system relative to [baseDir]. |
| final Uri relativeUri; |
| |
| /// A platform-independent URL representing the entry for the asset manifest. |
| final Uri entryUri; |
| |
| final AssetKind assetKind; |
| |
| final List<String> flavors; |
| |
| File lookupAssetFile(FileSystem fileSystem) { |
| return fileSystem.file(fileSystem.path.join(baseDir, fileSystem.path.fromUri(relativeUri))); |
| } |
| |
| /// The delta between what the entryUri is and the relativeUri (e.g., |
| /// packages/flutter_gallery). |
| Uri? get symbolicPrefixUri { |
| if (entryUri == relativeUri) { |
| return null; |
| } |
| final int index = entryUri.path.indexOf(relativeUri.path); |
| return index == -1 ? null : Uri(path: entryUri.path.substring(0, index)); |
| } |
| |
| bool matchesFlavor(String? flavor) { |
| if (flavors.isEmpty) { |
| return true; |
| } |
| |
| if (flavor == null) { |
| return false; |
| } |
| |
| return flavors.contains(flavor); |
| } |
| |
| bool hasEquivalentFlavorsWith(_Asset other) { |
| final Set<String> assetFlavors = flavors.toSet(); |
| final Set<String> otherFlavors = other.flavors.toSet(); |
| return assetFlavors.length == otherFlavors.length && assetFlavors.every( |
| (String e) => otherFlavors.contains(e), |
| ); |
| } |
| |
| @override |
| String toString() => 'asset: $entryUri'; |
| |
| @override |
| bool operator ==(Object other) { |
| if (identical(other, this)) { |
| return true; |
| } |
| if (other.runtimeType != runtimeType) { |
| return false; |
| } |
| return other is _Asset |
| && other.baseDir == baseDir |
| && other.relativeUri == relativeUri |
| && other.entryUri == entryUri |
| && other.assetKind == assetKind |
| && hasEquivalentFlavorsWith(other); |
| } |
| |
| @override |
| int get hashCode => Object.hashAll(<Object>[ |
| baseDir, |
| relativeUri, |
| entryUri, |
| assetKind, |
| ...flavors, |
| ]); |
| } |
| |
| // Given an assets directory like this: |
| // |
| // assets/foo.png |
| // assets/2x/foo.png |
| // assets/3.0x/foo.png |
| // assets/bar/foo.png |
| // assets/bar.png |
| // |
| // variantsFor('assets/foo.png') => ['/assets/foo.png', '/assets/2x/foo.png', 'assets/3.0x/foo.png'] |
| // variantsFor('assets/bar.png') => ['/assets/bar.png'] |
| // variantsFor('assets/bar/foo.png') => ['/assets/bar/foo.png'] |
| class _AssetDirectoryCache { |
| _AssetDirectoryCache(this._fileSystem); |
| |
| final FileSystem _fileSystem; |
| final Map<String, List<String>> _cache = <String, List<String>>{}; |
| final Map<String, List<File>> _variantsPerFolder = <String, List<File>>{}; |
| |
| List<String> variantsFor(String assetPath) { |
| final String directory = _fileSystem.path.dirname(assetPath); |
| |
| if (!_fileSystem.directory(directory).existsSync()) { |
| return const <String>[]; |
| } |
| |
| if (_cache.containsKey(assetPath)) { |
| return _cache[assetPath]!; |
| } |
| if (!_variantsPerFolder.containsKey(directory)) { |
| _variantsPerFolder[directory] = _fileSystem.directory(directory) |
| .listSync() |
| .whereType<Directory>() |
| .where((Directory dir) => _assetVariantDirectoryRegExp.hasMatch(dir.basename)) |
| .expand((Directory dir) => dir.listSync()) |
| .whereType<File>() |
| .toList(); |
| } |
| final File assetFile = _fileSystem.file(assetPath); |
| final List<File> potentialVariants = _variantsPerFolder[directory]!; |
| final String basename = assetFile.basename; |
| return _cache[assetPath] = <String>[ |
| // It's possible that the user specifies only explicit variants (e.g. .../1x/asset.png), |
| // so there does not necessarily need to be a file at the given path. |
| if (assetFile.existsSync()) |
| assetPath, |
| ...potentialVariants |
| .where((File file) => file.basename == basename) |
| .map((File file) => file.path), |
| ]; |
| } |
| } |