1// Copyright 2016 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6 7import 'package:yaml/yaml.dart'; 8 9import 'base/context.dart'; 10import 'base/file_system.dart'; 11import 'base/platform.dart'; 12import 'base/utils.dart'; 13import 'build_info.dart'; 14import 'cache.dart'; 15import 'convert.dart'; 16import 'dart/package_map.dart'; 17import 'devfs.dart'; 18import 'flutter_manifest.dart'; 19import 'globals.dart'; 20 21const AssetBundleFactory _kManifestFactory = _ManifestAssetBundleFactory(); 22 23const String defaultManifestPath = 'pubspec.yaml'; 24 25/// Injected factory class for spawning [AssetBundle] instances. 26abstract class AssetBundleFactory { 27 /// The singleton instance, pulled from the [AppContext]. 28 static AssetBundleFactory get instance => context.get<AssetBundleFactory>(); 29 30 static AssetBundleFactory get defaultInstance => _kManifestFactory; 31 32 /// Creates a new [AssetBundle]. 33 AssetBundle createBundle(); 34} 35 36abstract class AssetBundle { 37 Map<String, DevFSContent> get entries; 38 39 bool wasBuiltOnce(); 40 41 bool needsBuild({ String manifestPath = defaultManifestPath }); 42 43 /// Returns 0 for success; non-zero for failure. 44 Future<int> build({ 45 String manifestPath = defaultManifestPath, 46 String assetDirPath, 47 String packagesPath, 48 bool includeDefaultFonts = true, 49 bool reportLicensedPackages = false, 50 }); 51} 52 53class _ManifestAssetBundleFactory implements AssetBundleFactory { 54 const _ManifestAssetBundleFactory(); 55 56 @override 57 AssetBundle createBundle() => _ManifestAssetBundle(); 58} 59 60class _ManifestAssetBundle implements AssetBundle { 61 /// Constructs an [_ManifestAssetBundle] that gathers the set of assets from the 62 /// pubspec.yaml manifest. 63 _ManifestAssetBundle(); 64 65 @override 66 final Map<String, DevFSContent> entries = <String, DevFSContent>{}; 67 68 // If an asset corresponds to a wildcard directory, then it may have been 69 // updated without changes to the manifest. 70 final Map<Uri, Directory> _wildcardDirectories = <Uri, Directory>{}; 71 72 DateTime _lastBuildTimestamp; 73 74 static const String _assetManifestJson = 'AssetManifest.json'; 75 static const String _fontManifestJson = 'FontManifest.json'; 76 static const String _fontSetMaterial = 'material'; 77 static const String _license = 'LICENSE'; 78 79 @override 80 bool wasBuiltOnce() => _lastBuildTimestamp != null; 81 82 @override 83 bool needsBuild({ String manifestPath = defaultManifestPath }) { 84 if (_lastBuildTimestamp == null) 85 return true; 86 87 final FileStat stat = fs.file(manifestPath).statSync(); 88 if (stat.type == FileSystemEntityType.notFound) 89 return true; 90 91 for (Directory directory in _wildcardDirectories.values) { 92 final DateTime dateTime = directory.statSync().modified; 93 if (dateTime == null) { 94 continue; 95 } 96 if (dateTime.isAfter(_lastBuildTimestamp)) { 97 return true; 98 } 99 } 100 101 return stat.modified.isAfter(_lastBuildTimestamp); 102 } 103 104 @override 105 Future<int> build({ 106 String manifestPath = defaultManifestPath, 107 String assetDirPath, 108 String packagesPath, 109 bool includeDefaultFonts = true, 110 bool reportLicensedPackages = false, 111 }) async { 112 assetDirPath ??= getAssetBuildDirectory(); 113 packagesPath ??= fs.path.absolute(PackageMap.globalPackagesPath); 114 FlutterManifest flutterManifest; 115 try { 116 flutterManifest = FlutterManifest.createFromPath(manifestPath); 117 } catch (e) { 118 printStatus('Error detected in pubspec.yaml:', emphasis: true); 119 printError('$e'); 120 return 1; 121 } 122 if (flutterManifest == null) 123 return 1; 124 125 // If the last build time isn't set before this early return, empty pubspecs will 126 // hang on hot reload, as the incremental dill files will never be copied to the 127 // device. 128 _lastBuildTimestamp = DateTime.now(); 129 if (flutterManifest.isEmpty) { 130 entries[_assetManifestJson] = DevFSStringContent('{}'); 131 return 0; 132 } 133 134 final String assetBasePath = fs.path.dirname(fs.path.absolute(manifestPath)); 135 136 final PackageMap packageMap = PackageMap(packagesPath); 137 final List<Uri> wildcardDirectories = <Uri>[]; 138 139 // The _assetVariants map contains an entry for each asset listed 140 // in the pubspec.yaml file's assets and font and sections. The 141 // value of each image asset is a list of resolution-specific "variants", 142 // see _AssetDirectoryCache. 143 final Map<_Asset, List<_Asset>> assetVariants = _parseAssets( 144 packageMap, 145 flutterManifest, 146 wildcardDirectories, 147 assetBasePath, 148 excludeDirs: <String>[assetDirPath, getBuildDirectory()], 149 ); 150 151 if (assetVariants == null) { 152 return 1; 153 } 154 155 final List<Map<String, dynamic>> fonts = _parseFonts( 156 flutterManifest, 157 includeDefaultFonts, 158 packageMap, 159 ); 160 161 // Add fonts and assets from packages. 162 for (String packageName in packageMap.map.keys) { 163 final Uri package = packageMap.map[packageName]; 164 if (package != null && package.scheme == 'file') { 165 final String packageManifestPath = fs.path.fromUri(package.resolve('../pubspec.yaml')); 166 final FlutterManifest packageFlutterManifest = FlutterManifest.createFromPath(packageManifestPath); 167 if (packageFlutterManifest == null) 168 continue; 169 // Skip the app itself 170 if (packageFlutterManifest.appName == flutterManifest.appName) 171 continue; 172 final String packageBasePath = fs.path.dirname(packageManifestPath); 173 174 final Map<_Asset, List<_Asset>> packageAssets = _parseAssets( 175 packageMap, 176 packageFlutterManifest, 177 wildcardDirectories, 178 packageBasePath, 179 packageName: packageName, 180 ); 181 182 if (packageAssets == null) 183 return 1; 184 assetVariants.addAll(packageAssets); 185 186 fonts.addAll(_parseFonts( 187 packageFlutterManifest, 188 includeDefaultFonts, 189 packageMap, 190 packageName: packageName, 191 )); 192 } 193 } 194 195 // Save the contents of each image, image variant, and font 196 // asset in entries. 197 for (_Asset asset in assetVariants.keys) { 198 if (!asset.assetFileExists && assetVariants[asset].isEmpty) { 199 printStatus('Error detected in pubspec.yaml:', emphasis: true); 200 printError('No file or variants found for $asset.\n'); 201 return 1; 202 } 203 // The file name for an asset's "main" entry is whatever appears in 204 // the pubspec.yaml file. The main entry's file must always exist for 205 // font assets. It need not exist for an image if resolution-specific 206 // variant files exist. An image's main entry is treated the same as a 207 // "1x" resolution variant and if both exist then the explicit 1x 208 // variant is preferred. 209 if (asset.assetFileExists) { 210 assert(!assetVariants[asset].contains(asset)); 211 assetVariants[asset].insert(0, asset); 212 } 213 for (_Asset variant in assetVariants[asset]) { 214 assert(variant.assetFileExists); 215 entries[variant.entryUri.path] ??= DevFSFileContent(variant.assetFile); 216 } 217 } 218 219 final List<_Asset> materialAssets = <_Asset>[ 220 if (flutterManifest.usesMaterialDesign && includeDefaultFonts) 221 ..._getMaterialAssets(_fontSetMaterial), 222 ]; 223 for (_Asset asset in materialAssets) { 224 assert(asset.assetFileExists); 225 entries[asset.entryUri.path] ??= DevFSFileContent(asset.assetFile); 226 } 227 228 // Update wildcard directories we we can detect changes in them. 229 for (Uri uri in wildcardDirectories) { 230 _wildcardDirectories[uri] ??= fs.directory(uri); 231 } 232 233 entries[_assetManifestJson] = _createAssetManifest(assetVariants); 234 235 entries[_fontManifestJson] = DevFSStringContent(json.encode(fonts)); 236 237 // TODO(ianh): Only do the following line if we've changed packages or if our LICENSE file changed 238 entries[_license] = await _obtainLicenses(packageMap, assetBasePath, reportPackages: reportLicensedPackages); 239 240 return 0; 241 } 242} 243 244class _Asset { 245 _Asset({ this.baseDir, this.relativeUri, this.entryUri }); 246 247 final String baseDir; 248 249 /// A platform-independent Uri where this asset can be found on disk on the 250 /// host system relative to [baseDir]. 251 final Uri relativeUri; 252 253 /// A platform-independent Uri representing the entry for the asset manifest. 254 final Uri entryUri; 255 256 File get assetFile { 257 return fs.file(fs.path.join(baseDir, fs.path.fromUri(relativeUri))); 258 } 259 260 bool get assetFileExists => assetFile.existsSync(); 261 262 /// The delta between what the entryUri is and the relativeUri (e.g., 263 /// packages/flutter_gallery). 264 Uri get symbolicPrefixUri { 265 if (entryUri == relativeUri) 266 return null; 267 final int index = entryUri.path.indexOf(relativeUri.path); 268 return index == -1 ? null : Uri(path: entryUri.path.substring(0, index)); 269 } 270 271 @override 272 String toString() => 'asset: $entryUri'; 273 274 @override 275 bool operator ==(dynamic other) { 276 if (identical(other, this)) 277 return true; 278 if (other.runtimeType != runtimeType) 279 return false; 280 final _Asset otherAsset = other; 281 return otherAsset.baseDir == baseDir 282 && otherAsset.relativeUri == relativeUri 283 && otherAsset.entryUri == entryUri; 284 } 285 286 @override 287 int get hashCode { 288 return baseDir.hashCode 289 ^ relativeUri.hashCode 290 ^ entryUri.hashCode; 291 } 292} 293 294Map<String, dynamic> _readMaterialFontsManifest() { 295 final String fontsPath = fs.path.join(fs.path.absolute(Cache.flutterRoot), 296 'packages', 'flutter_tools', 'schema', 'material_fonts.yaml'); 297 298 return castStringKeyedMap(loadYaml(fs.file(fontsPath).readAsStringSync())); 299} 300 301final Map<String, dynamic> _materialFontsManifest = _readMaterialFontsManifest(); 302 303List<Map<String, dynamic>> _getMaterialFonts(String fontSet) { 304 final List<dynamic> fontsList = _materialFontsManifest[fontSet]; 305 return fontsList?.map<Map<String, dynamic>>(castStringKeyedMap)?.toList(); 306} 307 308List<_Asset> _getMaterialAssets(String fontSet) { 309 final List<_Asset> result = <_Asset>[]; 310 311 for (Map<String, dynamic> family in _getMaterialFonts(fontSet)) { 312 for (Map<dynamic, dynamic> font in family['fonts']) { 313 final Uri entryUri = fs.path.toUri(font['asset']); 314 result.add(_Asset( 315 baseDir: fs.path.join(Cache.flutterRoot, 'bin', 'cache', 'artifacts', 'material_fonts'), 316 relativeUri: Uri(path: entryUri.pathSegments.last), 317 entryUri: entryUri, 318 )); 319 } 320 } 321 322 return result; 323} 324 325final String _licenseSeparator = '\n' + ('-' * 80) + '\n'; 326 327/// Returns a DevFSContent representing the license file. 328Future<DevFSContent> _obtainLicenses( 329 PackageMap packageMap, 330 String assetBase, { 331 bool reportPackages, 332}) async { 333 // Read the LICENSE file from each package in the .packages file, splitting 334 // each one into each component license (so that we can de-dupe if possible). 335 // 336 // Individual licenses inside each LICENSE file should be separated by 80 337 // hyphens on their own on a line. 338 // 339 // If a LICENSE file contains more than one component license, then each 340 // component license must start with the names of the packages to which the 341 // component license applies, with each package name on its own line, and the 342 // list of package names separated from the actual license text by a blank 343 // line. (The packages need not match the names of the pub package. For 344 // example, a package might itself contain code from multiple third-party 345 // sources, and might need to include a license for each one.) 346 final Map<String, Set<String>> packageLicenses = <String, Set<String>>{}; 347 final Set<String> allPackages = <String>{}; 348 for (String packageName in packageMap.map.keys) { 349 final Uri package = packageMap.map[packageName]; 350 if (package != null && package.scheme == 'file') { 351 final File file = fs.file(package.resolve('../LICENSE')); 352 if (file.existsSync()) { 353 final List<String> rawLicenses = 354 (await file.readAsString()).split(_licenseSeparator); 355 for (String rawLicense in rawLicenses) { 356 List<String> packageNames; 357 String licenseText; 358 if (rawLicenses.length > 1) { 359 final int split = rawLicense.indexOf('\n\n'); 360 if (split >= 0) { 361 packageNames = rawLicense.substring(0, split).split('\n'); 362 licenseText = rawLicense.substring(split + 2); 363 } 364 } 365 if (licenseText == null) { 366 packageNames = <String>[packageName]; 367 licenseText = rawLicense; 368 } 369 packageLicenses.putIfAbsent(licenseText, () => <String>{}) 370 ..addAll(packageNames); 371 allPackages.addAll(packageNames); 372 } 373 } 374 } 375 } 376 377 if (reportPackages) { 378 final List<String> allPackagesList = allPackages.toList()..sort(); 379 printStatus('Licenses were found for the following packages:'); 380 printStatus(allPackagesList.join(', ')); 381 } 382 383 final List<String> combinedLicensesList = packageLicenses.keys.map<String>( 384 (String license) { 385 final List<String> packageNames = packageLicenses[license].toList() 386 ..sort(); 387 return packageNames.join('\n') + '\n\n' + license; 388 } 389 ).toList(); 390 combinedLicensesList.sort(); 391 392 final String combinedLicenses = combinedLicensesList.join(_licenseSeparator); 393 394 return DevFSStringContent(combinedLicenses); 395} 396 397int _byBasename(_Asset a, _Asset b) { 398 return a.assetFile.basename.compareTo(b.assetFile.basename); 399} 400 401DevFSContent _createAssetManifest(Map<_Asset, List<_Asset>> assetVariants) { 402 final Map<String, List<String>> jsonObject = <String, List<String>>{}; 403 404 // necessary for making unit tests deterministic 405 final List<_Asset> sortedKeys = assetVariants 406 .keys.toList() 407 ..sort(_byBasename); 408 409 for (_Asset main in sortedKeys) { 410 final List<String> variants = <String>[]; 411 for (_Asset variant in assetVariants[main]) 412 variants.add(variant.entryUri.path); 413 jsonObject[main.entryUri.path] = variants; 414 } 415 return DevFSStringContent(json.encode(jsonObject)); 416} 417 418List<Map<String, dynamic>> _parseFonts( 419 FlutterManifest manifest, 420 bool includeDefaultFonts, 421 PackageMap packageMap, { 422 String packageName, 423}) { 424 return <Map<String, dynamic>>[ 425 if (manifest.usesMaterialDesign && includeDefaultFonts) 426 ..._getMaterialFonts(_ManifestAssetBundle._fontSetMaterial), 427 if (packageName == null) 428 ...manifest.fontsDescriptor 429 else 430 ..._createFontsDescriptor(_parsePackageFonts( 431 manifest, 432 packageName, 433 packageMap, 434 )), 435 ]; 436} 437 438/// Prefixes family names and asset paths of fonts included from packages with 439/// 'packages/<package_name>' 440List<Font> _parsePackageFonts( 441 FlutterManifest manifest, 442 String packageName, 443 PackageMap packageMap, 444) { 445 final List<Font> packageFonts = <Font>[]; 446 for (Font font in manifest.fonts) { 447 final List<FontAsset> packageFontAssets = <FontAsset>[]; 448 for (FontAsset fontAsset in font.fontAssets) { 449 final Uri assetUri = fontAsset.assetUri; 450 if (assetUri.pathSegments.first == 'packages' && 451 !fs.isFileSync(fs.path.fromUri(packageMap.map[packageName].resolve('../${assetUri.path}')))) { 452 packageFontAssets.add(FontAsset( 453 fontAsset.assetUri, 454 weight: fontAsset.weight, 455 style: fontAsset.style, 456 )); 457 } else { 458 packageFontAssets.add(FontAsset( 459 Uri(pathSegments: <String>['packages', packageName, ...assetUri.pathSegments]), 460 weight: fontAsset.weight, 461 style: fontAsset.style, 462 )); 463 } 464 } 465 packageFonts.add(Font('packages/$packageName/${font.familyName}', packageFontAssets)); 466 } 467 return packageFonts; 468} 469 470List<Map<String, dynamic>> _createFontsDescriptor(List<Font> fonts) { 471 return fonts.map<Map<String, dynamic>>((Font font) => font.descriptor).toList(); 472} 473 474// Given an assets directory like this: 475// 476// assets/foo 477// assets/var1/foo 478// assets/var2/foo 479// assets/bar 480// 481// variantsFor('assets/foo') => ['/assets/var1/foo', '/assets/var2/foo'] 482// variantsFor('assets/bar') => [] 483class _AssetDirectoryCache { 484 _AssetDirectoryCache(Iterable<String> excluded) { 485 _excluded = excluded.map<String>((String path) => fs.path.absolute(path) + fs.path.separator); 486 } 487 488 Iterable<String> _excluded; 489 final Map<String, Map<String, List<String>>> _cache = <String, Map<String, List<String>>>{}; 490 491 List<String> variantsFor(String assetPath) { 492 final String assetName = fs.path.basename(assetPath); 493 final String directory = fs.path.dirname(assetPath); 494 495 if (!fs.directory(directory).existsSync()) 496 return const <String>[]; 497 498 if (_cache[directory] == null) { 499 final List<String> paths = <String>[]; 500 for (FileSystemEntity entity in fs.directory(directory).listSync(recursive: true)) { 501 final String path = entity.path; 502 if (fs.isFileSync(path) && !_excluded.any((String exclude) => path.startsWith(exclude))) 503 paths.add(path); 504 } 505 506 final Map<String, List<String>> variants = <String, List<String>>{}; 507 for (String path in paths) { 508 final String variantName = fs.path.basename(path); 509 if (directory == fs.path.dirname(path)) 510 continue; 511 variants[variantName] ??= <String>[]; 512 variants[variantName].add(path); 513 } 514 _cache[directory] = variants; 515 } 516 517 return _cache[directory][assetName] ?? const <String>[]; 518 } 519} 520 521/// Given an assetBase location and a pubspec.yaml Flutter manifest, return a 522/// map of assets to asset variants. 523/// 524/// Returns null on missing assets. 525/// 526/// Given package: 'test_package' and an assets directory like this: 527/// 528/// assets/foo 529/// assets/var1/foo 530/// assets/var2/foo 531/// assets/bar 532/// 533/// returns 534/// { 535/// asset: packages/test_package/assets/foo: [ 536/// asset: packages/test_package/assets/foo, 537/// asset: packages/test_package/assets/var1/foo, 538/// asset: packages/test_package/assets/var2/foo, 539/// ], 540/// asset: packages/test_package/assets/bar: [ 541/// asset: packages/test_package/assets/bar, 542/// ], 543/// } 544/// 545 546Map<_Asset, List<_Asset>> _parseAssets( 547 PackageMap packageMap, 548 FlutterManifest flutterManifest, 549 List<Uri> wildcardDirectories, 550 String assetBase, { 551 List<String> excludeDirs = const <String>[], 552 String packageName, 553}) { 554 final Map<_Asset, List<_Asset>> result = <_Asset, List<_Asset>>{}; 555 556 final _AssetDirectoryCache cache = _AssetDirectoryCache(excludeDirs); 557 for (Uri assetUri in flutterManifest.assets) { 558 if (assetUri.toString().endsWith('/')) { 559 wildcardDirectories.add(assetUri); 560 _parseAssetsFromFolder(packageMap, flutterManifest, assetBase, 561 cache, result, assetUri, 562 excludeDirs: excludeDirs, packageName: packageName); 563 } else { 564 _parseAssetFromFile(packageMap, flutterManifest, assetBase, 565 cache, result, assetUri, 566 excludeDirs: excludeDirs, packageName: packageName); 567 } 568 } 569 570 // Add assets referenced in the fonts section of the manifest. 571 for (Font font in flutterManifest.fonts) { 572 for (FontAsset fontAsset in font.fontAssets) { 573 final _Asset baseAsset = _resolveAsset( 574 packageMap, 575 assetBase, 576 fontAsset.assetUri, 577 packageName, 578 ); 579 if (!baseAsset.assetFileExists) { 580 printError('Error: unable to locate asset entry in pubspec.yaml: "${fontAsset.assetUri}".'); 581 return null; 582 } 583 584 result[baseAsset] = <_Asset>[]; 585 } 586 } 587 588 return result; 589} 590 591void _parseAssetsFromFolder( 592 PackageMap packageMap, 593 FlutterManifest flutterManifest, 594 String assetBase, 595 _AssetDirectoryCache cache, 596 Map<_Asset, List<_Asset>> result, 597 Uri assetUri, { 598 List<String> excludeDirs = const <String>[], 599 String packageName, 600}) { 601 final String directoryPath = fs.path.join( 602 assetBase, assetUri.toFilePath(windows: platform.isWindows)); 603 604 if (!fs.directory(directoryPath).existsSync()) { 605 printError('Error: unable to find directory entry in pubspec.yaml: $directoryPath'); 606 return; 607 } 608 609 final List<FileSystemEntity> lister = fs.directory(directoryPath).listSync(); 610 611 for (FileSystemEntity entity in lister) { 612 if (entity is File) { 613 final String relativePath = fs.path.relative(entity.path, from: assetBase); 614 615 final Uri uri = Uri.file(relativePath, windows: platform.isWindows); 616 617 _parseAssetFromFile(packageMap, flutterManifest, assetBase, cache, result, 618 uri, packageName: packageName); 619 } 620 } 621} 622 623void _parseAssetFromFile( 624 PackageMap packageMap, 625 FlutterManifest flutterManifest, 626 String assetBase, 627 _AssetDirectoryCache cache, 628 Map<_Asset, List<_Asset>> result, 629 Uri assetUri, { 630 List<String> excludeDirs = const <String>[], 631 String packageName, 632}) { 633 final _Asset asset = _resolveAsset( 634 packageMap, 635 assetBase, 636 assetUri, 637 packageName, 638 ); 639 final List<_Asset> variants = <_Asset>[]; 640 for (String path in cache.variantsFor(asset.assetFile.path)) { 641 final String relativePath = fs.path.relative(path, from: asset.baseDir); 642 final Uri relativeUri = fs.path.toUri(relativePath); 643 final Uri entryUri = asset.symbolicPrefixUri == null 644 ? relativeUri 645 : asset.symbolicPrefixUri.resolveUri(relativeUri); 646 647 variants.add( 648 _Asset( 649 baseDir: asset.baseDir, 650 entryUri: entryUri, 651 relativeUri: relativeUri, 652 ) 653 ); 654 } 655 656 result[asset] = variants; 657} 658 659_Asset _resolveAsset( 660 PackageMap packageMap, 661 String assetsBaseDir, 662 Uri assetUri, 663 String packageName, 664) { 665 final String assetPath = fs.path.fromUri(assetUri); 666 if (assetUri.pathSegments.first == 'packages' && !fs.isFileSync(fs.path.join(assetsBaseDir, assetPath))) { 667 // The asset is referenced in the pubspec.yaml as 668 // 'packages/PACKAGE_NAME/PATH/TO/ASSET . 669 final _Asset packageAsset = _resolvePackageAsset(assetUri, packageMap); 670 if (packageAsset != null) 671 return packageAsset; 672 } 673 674 return _Asset( 675 baseDir: assetsBaseDir, 676 entryUri: packageName == null 677 ? assetUri // Asset from the current application. 678 : Uri(pathSegments: <String>['packages', packageName, ...assetUri.pathSegments]), // Asset from, and declared in $packageName. 679 relativeUri: assetUri, 680 ); 681} 682 683_Asset _resolvePackageAsset(Uri assetUri, PackageMap packageMap) { 684 assert(assetUri.pathSegments.first == 'packages'); 685 if (assetUri.pathSegments.length > 1) { 686 final String packageName = assetUri.pathSegments[1]; 687 final Uri packageUri = packageMap.map[packageName]; 688 if (packageUri != null && packageUri.scheme == 'file') { 689 return _Asset( 690 baseDir: fs.path.fromUri(packageUri), 691 entryUri: assetUri, 692 relativeUri: Uri(pathSegments: assetUri.pathSegments.sublist(2)), 693 ); 694 } 695 } 696 printStatus('Error detected in pubspec.yaml:', emphasis: true); 697 printError('Could not resolve package for asset $assetUri.\n'); 698 return null; 699} 700