Tighten asset variant detection criteria to only include device-pixel-ratio variants (#110721)

diff --git a/dev/integration_tests/flutter_gallery/pubspec.yaml b/dev/integration_tests/flutter_gallery/pubspec.yaml
index bd4a6d6..445505a 100644
--- a/dev/integration_tests/flutter_gallery/pubspec.yaml
+++ b/dev/integration_tests/flutter_gallery/pubspec.yaml
@@ -169,7 +169,6 @@
     - packages/flutter_gallery_assets/products/table.png
     - packages/flutter_gallery_assets/products/teaset.png
     - packages/flutter_gallery_assets/products/top.png
-    - packages/flutter_gallery_assets/people/ali.png
     - packages/flutter_gallery_assets/people/square/ali.png
     - packages/flutter_gallery_assets/people/square/peter.png
     - packages/flutter_gallery_assets/people/square/sandra.png
diff --git a/packages/flutter_tools/lib/src/asset.dart b/packages/flutter_tools/lib/src/asset.dart
index c7e438c..c45d611 100644
--- a/packages/flutter_tools/lib/src/asset.dart
+++ b/packages/flutter_tools/lib/src/asset.dart
@@ -23,6 +23,9 @@
 
 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:
 ///
@@ -92,7 +95,6 @@
   /// Returns 0 for success; non-zero for failure.
   Future<int> build({
     String manifestPath = defaultManifestPath,
-    String? assetDirPath,
     required String packagesPath,
     bool deferredComponentsEnabled = false,
     TargetPlatform? targetPlatform,
@@ -205,23 +207,22 @@
   @override
   Future<int> build({
     String manifestPath = defaultManifestPath,
-    String? assetDirPath,
+    FlutterProject? flutterProject,
     required String packagesPath,
     bool deferredComponentsEnabled = false,
     TargetPlatform? targetPlatform,
   }) async {
-    assetDirPath ??= getAssetBuildDirectory();
-    FlutterProject flutterProject;
-    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;
-    }
+
     if (flutterProject == null) {
-      return 1;
+      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
@@ -243,27 +244,14 @@
     final List<Uri> wildcardDirectories = <Uri>[];
 
     // The _assetVariants map contains an entry for each asset listed
-    // in the pubspec.yaml file's assets and font and sections. The
+    // 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 List<String> excludeDirs = <String>[
-      assetDirPath,
-      getBuildDirectory(),
-      if (flutterProject.ios.existsSync())
-        flutterProject.ios.hostAppRoot.path,
-      if (flutterProject.macos.existsSync())
-        flutterProject.macos.managedDirectory.path,
-      if (flutterProject.windows.existsSync())
-        flutterProject.windows.managedDirectory.path,
-      if (flutterProject.linux.existsSync())
-        flutterProject.linux.managedDirectory.path,
-    ];
     final Map<_Asset, List<_Asset>>? assetVariants = _parseAssets(
       packageConfig,
       flutterManifest,
       wildcardDirectories,
       assetBasePath,
-      excludeDirs: excludeDirs,
     );
 
     if (assetVariants == null) {
@@ -277,7 +265,6 @@
       assetBasePath,
       wildcardDirectories,
       flutterProject.directory,
-      excludeDirs: excludeDirs,
     );
     if (!_splitDeferredAssets || !deferredComponentsEnabled) {
       // Include the assets in the regular set of assets if not using deferred
@@ -373,8 +360,7 @@
       // 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()) {
-        assert(!variants.contains(asset));
+      if (assetFile.existsSync() && !variants.contains(asset)) {
         variants.insert(0, asset);
       }
       for (final _Asset variant in variants) {
@@ -407,8 +393,7 @@
           // 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()) {
-            assert(!assetsMap[asset]!.contains(asset));
+          if (assetFile.existsSync() && !assetsMap[asset]!.contains(asset)) {
             assetsMap[asset]!.insert(0, asset);
           }
           for (final _Asset variant in assetsMap[asset]!) {
@@ -606,7 +591,7 @@
     }
     for (final DeferredComponent component in components) {
       deferredComponentsAssetVariants[component.name] = <_Asset, List<_Asset>>{};
-      final _AssetDirectoryCache cache = _AssetDirectoryCache(<String>[], _fileSystem);
+      final _AssetDirectoryCache cache = _AssetDirectoryCache(_fileSystem);
       for (final Uri assetUri in component.assets) {
         if (assetUri.path.endsWith('/')) {
           wildcardDirectories.add(assetUri);
@@ -617,7 +602,6 @@
             cache,
             deferredComponentsAssetVariants[component.name]!,
             assetUri,
-            excludeDirs: excludeDirs,
           );
         } else {
           _parseAssetFromFile(
@@ -728,13 +712,12 @@
     FlutterManifest flutterManifest,
     List<Uri> wildcardDirectories,
     String assetBase, {
-    List<String> excludeDirs = const <String>[],
     String? packageName,
     Package? attributedPackage,
   }) {
     final Map<_Asset, List<_Asset>> result = <_Asset, List<_Asset>>{};
 
-    final _AssetDirectoryCache cache = _AssetDirectoryCache(excludeDirs, _fileSystem);
+    final _AssetDirectoryCache cache = _AssetDirectoryCache(_fileSystem);
     for (final Uri assetUri in flutterManifest.assets) {
       if (assetUri.path.endsWith('/')) {
         wildcardDirectories.add(assetUri);
@@ -745,7 +728,6 @@
           cache,
           result,
           assetUri,
-          excludeDirs: excludeDirs,
           packageName: packageName,
           attributedPackage: attributedPackage,
         );
@@ -757,7 +739,6 @@
           cache,
           result,
           assetUri,
-          excludeDirs: excludeDirs,
           packageName: packageName,
           attributedPackage: attributedPackage,
         );
@@ -772,7 +753,6 @@
         cache,
         result,
         shaderUri,
-        excludeDirs: excludeDirs,
         packageName: packageName,
         attributedPackage: attributedPackage,
         assetKind: AssetKind.shader,
@@ -808,7 +788,6 @@
     _AssetDirectoryCache cache,
     Map<_Asset, List<_Asset>> result,
     Uri assetUri, {
-    List<String> excludeDirs = const <String>[],
     String? packageName,
     Package? attributedPackage,
   }) {
@@ -820,10 +799,9 @@
       return;
     }
 
-    final Iterable<File> files = _fileSystem
-      .directory(directoryPath)
-      .listSync()
-      .whereType<File>();
+    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);
@@ -839,6 +817,22 @@
         attributedPackage: attributedPackage,
       );
     }
+
+    final Iterable<Directory> nonVariantSubDirectories = entities
+      .whereType<Directory>()
+      .where((Directory directory) => !_assetVariantDirectoryRegExp.hasMatch(directory.basename));
+    for (final Directory dir in nonVariantSubDirectories) {
+      final String relativePath = _fileSystem.path.relative(dir.path, from: assetBase);
+      final Uri relativePathsUri = Uri.directory(relativePath, windows: _platform.isWindows);
+
+      _parseAssetsFromFolder(packageConfig,
+        flutterManifest,
+        assetBase,
+        cache,
+        result,
+        relativePathsUri
+      );
+    }
   }
 
   void _parseAssetFromFile(
@@ -1011,54 +1005,48 @@
 
 // Given an assets directory like this:
 //
-// assets/foo
-// assets/var1/foo
-// assets/var2/foo
-// assets/bar
+// assets/foo.png
+// assets/2x/foo.png
+// assets/3.0x/foo.png
+// assets/bar/foo.png
+// assets/bar.png
 //
-// variantsFor('assets/foo') => ['/assets/var1/foo', '/assets/var2/foo']
-// variantsFor('assets/bar') => []
+// 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(Iterable<String> excluded, this._fileSystem)
-    : _excluded = excluded
-        .map<String>(_fileSystem.path.absolute)
-        .toList();
+  _AssetDirectoryCache(this._fileSystem);
 
   final FileSystem _fileSystem;
-  final List<String> _excluded;
-  final Map<String, Map<String, List<String>>> _cache = <String, Map<String, List<String>>>{};
+  final Map<String, List<String>> _cache = <String, List<String>>{};
 
   List<String> variantsFor(String assetPath) {
-    final String assetName = _fileSystem.path.basename(assetPath);
     final String directory = _fileSystem.path.dirname(assetPath);
 
     if (!_fileSystem.directory(directory).existsSync()) {
       return const <String>[];
     }
 
-    if (_cache[directory] == null) {
-      final List<String> paths = <String>[];
-      for (final FileSystemEntity entity in _fileSystem.directory(directory).listSync(recursive: true)) {
-        final String path = entity.path;
-        if (_fileSystem.isFileSync(path)
-          && assetPath != path
-          && !_excluded.any((String exclude) => _fileSystem.path.isWithin(exclude, path))) {
-          paths.add(path);
-        }
-      }
-
-      final Map<String, List<String>> variants = <String, List<String>>{};
-      for (final String path in paths) {
-        final String variantName = _fileSystem.path.basename(path);
-        if (directory == _fileSystem.path.dirname(path)) {
-          continue;
-        }
-        variants[variantName] ??= <String>[];
-        variants[variantName]!.add(path);
-      }
-      _cache[directory] = variants;
+    if (_cache.containsKey(assetPath)) {
+      return _cache[assetPath]!;
     }
 
-    return _cache[directory]![assetName] ?? const <String>[];
+    final List<FileSystemEntity> entitiesInDirectory = _fileSystem.directory(directory).listSync();
+
+    final List<String> pathsOfVariants = <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 (_fileSystem.file(assetPath).existsSync())
+        assetPath,
+      ...entitiesInDirectory
+        .whereType<Directory>()
+        .where((Directory dir) => _assetVariantDirectoryRegExp.hasMatch(dir.basename))
+        .expand((Directory dir) => dir.listSync())
+        .whereType<File>()
+        .map((File file) => file.path),
+    ];
+
+    _cache[assetPath] = pathsOfVariants;
+    return pathsOfVariants;
   }
 }
diff --git a/packages/flutter_tools/lib/src/bundle_builder.dart b/packages/flutter_tools/lib/src/bundle_builder.dart
index f41c682..50480ba 100644
--- a/packages/flutter_tools/lib/src/bundle_builder.dart
+++ b/packages/flutter_tools/lib/src/bundle_builder.dart
@@ -121,7 +121,6 @@
   final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle();
   final int result = await assetBundle.build(
     manifestPath: manifestPath,
-    assetDirPath: assetDirPath,
     packagesPath: packagesPath,
     targetPlatform: targetPlatform,
   );
diff --git a/packages/flutter_tools/test/general.shard/asset_bundle_package_test.dart b/packages/flutter_tools/test/general.shard/asset_bundle_package_test.dart
index dc4d868..1b651b3 100644
--- a/packages/flutter_tools/test/general.shard/asset_bundle_package_test.dart
+++ b/packages/flutter_tools/test/general.shard/asset_bundle_package_test.dart
@@ -222,11 +222,11 @@
         assets: <String>['a/foo'],
       );
 
-      final List<String> assets = <String>['a/foo', 'a/v/foo'];
+      final List<String> assets = <String>['a/foo', 'a/2x/foo'];
       writeAssets('p/p/', assets);
 
       const String expectedManifest = '{"packages/test_package/a/foo":'
-          '["packages/test_package/a/foo","packages/test_package/a/v/foo"]}';
+          '["packages/test_package/a/foo","packages/test_package/a/2x/foo"]}';
 
       await buildAndVerifyAssets(
         assets,
@@ -251,11 +251,11 @@
         'test_package',
       );
 
-      final List<String> assets = <String>['a/foo', 'a/v/foo'];
+      final List<String> assets = <String>['a/foo', 'a/2x/foo'];
       writeAssets('p/p/lib/', assets);
 
       const String expectedManifest = '{"packages/test_package/a/foo":'
-          '["packages/test_package/a/foo","packages/test_package/a/v/foo"]}';
+          '["packages/test_package/a/foo","packages/test_package/a/2x/foo"]}';
 
       await buildAndVerifyAssets(
         assets,
@@ -344,15 +344,15 @@
         assets: <String>['a/foo'],
       );
 
-      final List<String> assets = <String>['a/foo', 'a/v/foo'];
+      final List<String> assets = <String>['a/foo', 'a/2x/foo'];
       writeAssets('p/p/', assets);
       writeAssets('p2/p/', assets);
 
       const String expectedAssetManifest =
           '{"packages/test_package/a/foo":'
-          '["packages/test_package/a/foo","packages/test_package/a/v/foo"],'
+          '["packages/test_package/a/foo","packages/test_package/a/2x/foo"],'
           '"packages/test_package2/a/foo":'
-          '["packages/test_package2/a/foo","packages/test_package2/a/v/foo"]}';
+          '["packages/test_package2/a/foo","packages/test_package2/a/2x/foo"]}';
 
       await buildAndVerifyAssets(
         assets,
@@ -384,15 +384,15 @@
         'test_package2',
       );
 
-      final List<String> assets = <String>['a/foo', 'a/v/foo'];
+      final List<String> assets = <String>['a/foo', 'a/2x/foo'];
       writeAssets('p/p/lib/', assets);
       writeAssets('p2/p/lib/', assets);
 
       const String expectedAssetManifest =
           '{"packages/test_package/a/foo":'
-          '["packages/test_package/a/foo","packages/test_package/a/v/foo"],'
+          '["packages/test_package/a/foo","packages/test_package/a/2x/foo"],'
           '"packages/test_package2/a/foo":'
-          '["packages/test_package2/a/foo","packages/test_package2/a/v/foo"]}';
+          '["packages/test_package2/a/foo","packages/test_package2/a/2x/foo"]}';
 
       await buildAndVerifyAssets(
         assets,
@@ -421,12 +421,12 @@
         'test_package2',
       );
 
-      final List<String> assets = <String>['a/foo', 'a/v/foo'];
+      final List<String> assets = <String>['a/foo', 'a/2x/foo'];
       writeAssets('p2/p/lib/', assets);
 
       const String expectedAssetManifest =
           '{"packages/test_package2/a/foo":'
-          '["packages/test_package2/a/foo","packages/test_package2/a/v/foo"]}';
+          '["packages/test_package2/a/foo","packages/test_package2/a/2x/foo"]}';
 
       await buildAndVerifyAssets(
         assets,
@@ -553,7 +553,7 @@
       writePubspecFile('pubspec.yaml', 'test');
       writePackagesFile('test_package:p/p/lib/');
 
-      final List<String> assetsOnDisk = <String>['a/foo','a/b/foo'];
+      final List<String> assetsOnDisk = <String>['a/foo','a/2x/foo'];
       final List<String> assetOnManifest = <String>['a/',];
 
       writePubspecFile(
@@ -564,7 +564,7 @@
 
       writeAssets('p/p/', assetsOnDisk);
       const String expectedAssetManifest =
-          '{"packages/test_package/a/foo":["packages/test_package/a/foo","packages/test_package/a/b/foo"]}';
+          '{"packages/test_package/a/foo":["packages/test_package/a/foo","packages/test_package/a/2x/foo"]}';
 
       await buildAndVerifyAssets(
         assetsOnDisk,
@@ -580,7 +580,7 @@
       writePubspecFile('pubspec.yaml', 'test');
       writePackagesFile('test_package:p/p/lib/');
 
-      final List<String> assetsOnDisk = <String>['a/foo', 'a/b/foo'];
+      final List<String> assetsOnDisk = <String>['a/foo', 'a/2x/foo'];
       final List<String> assetOnManifest = <String>[];
 
       writePubspecFile(
diff --git a/packages/flutter_tools/test/general.shard/asset_bundle_test.dart b/packages/flutter_tools/test/general.shard/asset_bundle_test.dart
index 079f3f4..e2cd837 100644
--- a/packages/flutter_tools/test/general.shard/asset_bundle_test.dart
+++ b/packages/flutter_tools/test/general.shard/asset_bundle_test.dart
@@ -643,7 +643,7 @@
 
 flutter:
   assets:
-    - foo.txt
+    - assets/foo.txt
 ''');
     globals.fs.file('assets/foo.txt').createSync(recursive: true);
 
diff --git a/packages/flutter_tools/test/general.shard/asset_bundle_variant_test.dart b/packages/flutter_tools/test/general.shard/asset_bundle_variant_test.dart
index 5b2546c..c635a5f 100644
--- a/packages/flutter_tools/test/general.shard/asset_bundle_variant_test.dart
+++ b/packages/flutter_tools/test/general.shard/asset_bundle_variant_test.dart
@@ -9,37 +9,43 @@
 
 import 'package:flutter_tools/src/asset.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/base/logger.dart';
+import 'package:flutter_tools/src/base/platform.dart';
+import 'package:flutter_tools/src/base/user_messages.dart';
+import 'package:flutter_tools/src/cache.dart';
 
-import 'package:flutter_tools/src/globals.dart' as globals;
+import 'package:flutter_tools/src/project.dart';
 
 import '../src/common.dart';
-import '../src/context.dart';
 
 void main() {
-  String fixPath(String path) {
-    // The in-memory file system is strict about slashes on Windows being the
-    // correct way so until https://github.com/google/file.dart/issues/112 is
-    // fixed we fix them here.
-    // TODO(dantup): Remove this function once the above issue is fixed and
-    // rolls into Flutter.
-    return path.replaceAll('/', globals.fs.path.separator);
+
+  Future<Map<String, List<String>>> extractAssetManifestFromBundle(ManifestAssetBundle bundle) async {
+    final String manifestJson = utf8.decode(await bundle.entries['AssetManifest.json']!.contentsAsBytes());
+    final Map<String, dynamic> parsedJson = json.decode(manifestJson) as Map<String, dynamic>;
+    final Iterable<String> keys = parsedJson.keys;
+    final Map<String, List<String>> parsedManifest = <String, List<String>> {
+      for (final String key in keys) key: List<String>.from(parsedJson[key] as List<dynamic>),
+    };
+    return parsedManifest;
   }
 
-  group('AssetBundle asset variants', () {
-    late FileSystem testFileSystem;
-    setUp(() async {
-      testFileSystem = MemoryFileSystem(
-        style: globals.platform.isWindows
-          ? FileSystemStyle.windows
-          : FileSystemStyle.posix,
-      );
-      testFileSystem.currentDirectory = testFileSystem.systemTempDirectory.createTempSync('flutter_asset_bundle_variant_test.');
-    });
+  group('AssetBundle asset variants (with POSIX-style paths)', () {
+    late final Platform platform;
+    late final FileSystem fs;
 
-    testUsingContext('main asset and variants', () async {
-      globals.fs.file('pubspec.yaml')
-        ..createSync()
-        ..writeAsStringSync(
+    setUpAll(() {
+      platform = FakePlatform();
+      fs = MemoryFileSystem.test();
+      Cache.flutterRoot = Cache.defaultFlutterRoot(
+        platform: platform,
+        fileSystem: fs,
+        userMessages: UserMessages()
+      );
+
+      fs.file('.packages').createSync();
+
+      fs.file('pubspec.yaml').writeAsStringSync(
 '''
 name: test
 dependencies:
@@ -47,46 +53,187 @@
     sdk: flutter
 flutter:
   assets:
-    - a/b/c/foo
+    - assets/
 '''
       );
-      globals.fs.file('.packages').createSync();
+    });
+
+    testWithoutContext('Only images in folders named with device pixel ratios (e.g. 2x, 3.0x) should be considered as variants of other images', () async {
+      const String image = 'assets/image.jpg';
+      const String image2xVariant = 'assets/2x/image.jpg';
+      const String imageNonVariant = 'assets/notAVariant/image.jpg';
 
       final List<String> assets = <String>[
-        'a/b/c/foo',
-        'a/b/c/var1/foo',
-        'a/b/c/var2/foo',
-        'a/b/c/var3/foo',
+        image,
+        image2xVariant,
+        imageNonVariant
       ];
+
       for (final String asset in assets) {
-        globals.fs.file(fixPath(asset))
-          ..createSync(recursive: true)
-          ..writeAsStringSync(asset);
+        final File assetFile = fs.file(asset);
+        assetFile.createSync(recursive: true);
+        assetFile.writeAsStringSync(asset);
       }
 
-      AssetBundle bundle = AssetBundleFactory.instance.createBundle();
-      await bundle.build(packagesPath: '.packages');
+      final ManifestAssetBundle bundle = ManifestAssetBundle(
+        logger: BufferLogger.test(),
+        fileSystem: fs,
+        platform: platform,
+      );
 
-      // The main asset file, /a/b/c/foo, and its variants exist.
+      await bundle.build(
+        packagesPath: '.packages',
+        flutterProject:  FlutterProject.fromDirectoryTest(fs.currentDirectory),
+      );
+
+      final Map<String, List<String>> manifest = await extractAssetManifestFromBundle(bundle);
+      final List<String> variantsForImage = manifest[image]!;
+
+      expect(variantsForImage, contains(image2xVariant));
+      expect(variantsForImage, isNot(contains(imageNonVariant)));
+    });
+
+    testWithoutContext('Asset directories are recursively searched for assets', () async {
+      const String topLevelImage = 'assets/image.jpg';
+      const String secondLevelImage = 'assets/folder/secondLevel.jpg';
+      const String secondLevel2xVariant = 'assets/folder/2x/secondLevel.jpg';
+
+      final List<String> assets = <String>[
+        topLevelImage,
+        secondLevelImage,
+        secondLevel2xVariant
+      ];
+
       for (final String asset in assets) {
-        expect(bundle.entries.containsKey(asset), true);
-        expect(utf8.decode(await bundle.entries[asset]!.contentsAsBytes()), asset);
+        final File assetFile = fs.file(asset);
+        assetFile.createSync(recursive: true);
+        assetFile.writeAsStringSync(asset);
       }
 
-      globals.fs.file(fixPath('a/b/c/foo')).deleteSync();
-      bundle = AssetBundleFactory.instance.createBundle();
-      await bundle.build(packagesPath: '.packages');
+      final ManifestAssetBundle bundle = ManifestAssetBundle(
+        logger: BufferLogger.test(),
+        fileSystem: fs,
+        platform: platform,
+      );
 
-      // Now the main asset file, /a/b/c/foo, does not exist. This is OK because
-      // the /a/b/c/*/foo variants do exist.
-      expect(bundle.entries.containsKey('a/b/c/foo'), false);
-      for (final String asset in assets.skip(1)) {
-        expect(bundle.entries.containsKey(asset), true);
-        expect(utf8.decode(await bundle.entries[asset]!.contentsAsBytes()), asset);
+      await bundle.build(
+        packagesPath: '.packages',
+        flutterProject:  FlutterProject.fromDirectoryTest(fs.currentDirectory),
+      );
+
+      final Map<String, List<String>> manifest = await extractAssetManifestFromBundle(bundle);
+      expect(manifest, contains(secondLevelImage));
+      expect(manifest, contains(topLevelImage));
+      expect(manifest[secondLevelImage], hasLength(2));
+      expect(manifest[secondLevelImage], contains(secondLevelImage));
+      expect(manifest[secondLevelImage], contains(secondLevel2xVariant));
+    });
+  });
+
+
+  group('AssetBundle asset variants (with Windows-style filepaths)', () {
+    late final Platform platform;
+    late final FileSystem fs;
+
+    String correctPathSeparators(String path) {
+      // The in-memory file system is strict about slashes on Windows being the
+      // correct way. See https://github.com/google/file.dart/issues/112.
+      return path.replaceAll('/', fs.path.separator);
+    }
+
+    setUpAll(() {
+      platform = FakePlatform(operatingSystem: 'windows');
+      fs = MemoryFileSystem.test(style: FileSystemStyle.windows);
+      Cache.flutterRoot = Cache.defaultFlutterRoot(
+        platform: platform,
+        fileSystem: fs,
+        userMessages: UserMessages()
+      );
+
+      fs.file('.packages').createSync();
+
+      fs.file('pubspec.yaml').writeAsStringSync(
+'''
+name: test
+dependencies:
+  flutter:
+    sdk: flutter
+flutter:
+  assets:
+    - assets/
+'''
+      );
+    });
+
+    testWithoutContext('Only images in folders named with device pixel ratios (e.g. 2x, 3.0x) should be considered as variants of other images', () async {
+      const String image = 'assets/image.jpg';
+      const String image2xVariant = 'assets/2x/image.jpg';
+      const String imageNonVariant = 'assets/notAVariant/image.jpg';
+
+      final List<String> assets = <String>[
+        image,
+        image2xVariant,
+        imageNonVariant
+      ];
+
+      for (final String asset in assets) {
+        final File assetFile = fs.file(correctPathSeparators(asset));
+        assetFile.createSync(recursive: true);
+        assetFile.writeAsStringSync(asset);
       }
-    }, overrides: <Type, Generator>{
-      FileSystem: () => testFileSystem,
-      ProcessManager: () => FakeProcessManager.any(),
+
+      final ManifestAssetBundle bundle = ManifestAssetBundle(
+        logger: BufferLogger.test(),
+        fileSystem: fs,
+        platform: platform,
+      );
+
+      await bundle.build(
+        packagesPath: '.packages',
+        flutterProject:  FlutterProject.fromDirectoryTest(fs.currentDirectory),
+      );
+
+      final Map<String, List<String>> manifest = await extractAssetManifestFromBundle(bundle);
+      final List<String> variantsForImage = manifest[image]!;
+
+      expect(variantsForImage, contains(image2xVariant));
+      expect(variantsForImage, isNot(contains(imageNonVariant)));
+    });
+
+    testWithoutContext('Asset directories are recursively searched for assets', () async {
+      const String topLevelImage = 'assets/image.jpg';
+      const String secondLevelImage = 'assets/folder/secondLevel.jpg';
+      const String secondLevel2xVariant = 'assets/folder/2x/secondLevel.jpg';
+
+      final List<String> assets = <String>[
+        topLevelImage,
+        secondLevelImage,
+        secondLevel2xVariant
+      ];
+
+      for (final String asset in assets) {
+        final File assetFile = fs.file(correctPathSeparators(asset));
+        assetFile.createSync(recursive: true);
+        assetFile.writeAsStringSync(asset);
+      }
+
+      final ManifestAssetBundle bundle = ManifestAssetBundle(
+        logger: BufferLogger.test(),
+        fileSystem: fs,
+        platform: platform,
+      );
+
+      await bundle.build(
+        packagesPath: '.packages',
+        flutterProject:  FlutterProject.fromDirectoryTest(fs.currentDirectory),
+      );
+
+      final Map<String, List<String>> manifest = await extractAssetManifestFromBundle(bundle);
+      expect(manifest, contains(secondLevelImage));
+      expect(manifest, contains(topLevelImage));
+      expect(manifest[secondLevelImage], hasLength(2));
+      expect(manifest[secondLevelImage], contains(secondLevelImage));
+      expect(manifest[secondLevelImage], contains(secondLevel2xVariant));
     });
   });
 }