Reland "Add API for discovering assets" (#119277)

* add asset manifest bin loading and asset manifest api

* use new api for image resolution

* remove upfront smc data casting

* fix typecasting issue

* remove unused import

* fix tests

* lints

* lints

* fix import

* revert image resolution changes

* Update image_resolution_test.dart

* Update decode_and_parse_asset_manifest.dart

* make targetDevicePixelRatio optional

* Update packages/flutter/lib/src/services/asset_manifest.dart

Co-authored-by: Jonah Williams <jonahwilliams@google.com>

* Update packages/flutter/lib/src/services/asset_manifest.dart

Co-authored-by: Jonah Williams <jonahwilliams@google.com>

* fix immutable not being imported

* return List in AssetManifest methods, fix annotation import

* simplify onError callback

* make AssetManifest methods abstract instead of throwing UnimplementedError

* simplify AssetVariant.key docstring

* tweak _AssetManifestBin docstring

* make AssetManifest and AssetVariant doc strings more specific

* use List.of instead of List.from for type-safety

* adjust import

* change _AssetManifestBin comment from doc comment to normal comment

* revert to callback function for onError in loadStructuredBinaryData

* add more to the docstring of AssetManifest.listAssets and AssetVariant.key

* add tests for CachingAssetBundle caching behavior

* add simple test to ensure loadStructuredBinaryData correctly calls load

* Update asset_manifest.dart

* update docstring for AssetManifest.getAssetVariants

* rename getAssetVariants, have it include main asset

* rename isMainAsset field of AssetMetadata to main

* (slightly) shorten name of describeAssetAndVariants

* rename describeAssetVariants back to getAssetVariants

* add tests for TestAssetBundle

* nits

* fix typo in docstring

* remove no longer necessary non-null asserts

* update gallery and google_fonts versions

---------

Co-authored-by: Jonah Williams <jonahwilliams@google.com>
diff --git a/dev/benchmarks/microbenchmarks/lib/foundation/decode_and_parse_asset_manifest.dart b/dev/benchmarks/microbenchmarks/lib/foundation/decode_and_parse_asset_manifest.dart
index b64c153..5082abc 100644
--- a/dev/benchmarks/microbenchmarks/lib/foundation/decode_and_parse_asset_manifest.dart
+++ b/dev/benchmarks/microbenchmarks/lib/foundation/decode_and_parse_asset_manifest.dart
@@ -2,10 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:convert';
-
-import 'package:flutter/foundation.dart';
-import 'package:flutter/services.dart' show PlatformAssetBundle;
+import 'package:flutter/services.dart' show AssetManifest, PlatformAssetBundle, rootBundle;
 import 'package:flutter/widgets.dart';
 
 import '../common.dart';
@@ -18,16 +15,12 @@
   final BenchmarkResultPrinter printer = BenchmarkResultPrinter();
   WidgetsFlutterBinding.ensureInitialized();
   final Stopwatch watch = Stopwatch();
-  final PlatformAssetBundle bundle = PlatformAssetBundle();
+  final PlatformAssetBundle bundle = rootBundle as PlatformAssetBundle;
 
-  final ByteData assetManifestBytes = await bundle.load('money_asset_manifest.json');
   watch.start();
   for (int i = 0; i < _kNumIterations; i++) {
+    await AssetManifest.loadFromAssetBundle(bundle);
     bundle.clear();
-    final String json = utf8.decode(assetManifestBytes.buffer.asUint8List());
-    // This is a test, so we don't need to worry about this rule.
-    // ignore: invalid_use_of_visible_for_testing_member
-    await AssetImage.manifestParser(json);
   }
   watch.stop();
 
diff --git a/dev/devicelab/lib/versions/gallery.dart b/dev/devicelab/lib/versions/gallery.dart
index 3c1e217..b604b06 100644
--- a/dev/devicelab/lib/versions/gallery.dart
+++ b/dev/devicelab/lib/versions/gallery.dart
@@ -3,4 +3,4 @@
 // found in the LICENSE file.
 
 /// The pinned version of flutter gallery, used for devicelab tests.
-const String galleryVersion = 'b6728704a6441ac37a21e433a1e43c990780d47b';
+const String galleryVersion = 'afcf15fe40d8b9243bad30895d3ba1ad49014550';
diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart
index 681c918..7e7fb02 100644
--- a/packages/flutter/lib/services.dart
+++ b/packages/flutter/lib/services.dart
@@ -11,6 +11,7 @@
 library services;
 
 export 'src/services/asset_bundle.dart';
+export 'src/services/asset_manifest.dart';
 export 'src/services/autofill.dart';
 export 'src/services/binary_messenger.dart';
 export 'src/services/binding.dart';
diff --git a/packages/flutter/lib/src/services/asset_bundle.dart b/packages/flutter/lib/src/services/asset_bundle.dart
index 971fdaf..da2719c 100644
--- a/packages/flutter/lib/src/services/asset_bundle.dart
+++ b/packages/flutter/lib/src/services/asset_bundle.dart
@@ -96,12 +96,22 @@
   }
 
   /// Retrieve a string from the asset bundle, parse it with the given function,
-  /// and return the function's result.
+  /// and return that function's result.
   ///
   /// Implementations may cache the result, so a particular key should only be
   /// used with one parser for the lifetime of the asset bundle.
   Future<T> loadStructuredData<T>(String key, Future<T> Function(String value) parser);
 
+  /// Retrieve [ByteData] from the asset bundle, parse it with the given function,
+  /// and return that function's result.
+  ///
+  /// Implementations may cache the result, so a particular key should only be
+  /// used with one parser for the lifetime of the asset bundle.
+  Future<T> loadStructuredBinaryData<T>(String key, FutureOr<T> Function(ByteData data) parser) async {
+    final ByteData data = await load(key);
+    return parser(data);
+  }
+
   /// If this is a caching asset bundle, and the given key describes a cached
   /// asset, then evict the asset from the cache so that the next time it is
   /// loaded, the cache will be reread from the asset bundle.
@@ -154,6 +164,16 @@
     return parser(await loadString(key));
   }
 
+  /// Retrieve [ByteData] from the asset bundle, parse it with the given function,
+  /// and return the function's result.
+  ///
+  /// The result is not cached. The parser is run each time the resource is
+  /// fetched.
+  @override
+  Future<T> loadStructuredBinaryData<T>(String key, FutureOr<T> Function(ByteData data) parser) async {
+    return parser(await load(key));
+  }
+
   // TODO(ianh): Once the underlying network logic learns about caching, we
   // should implement evict().
 
@@ -173,6 +193,7 @@
   // TODO(ianh): Replace this with an intelligent cache, see https://github.com/flutter/flutter/issues/3568
   final Map<String, Future<String>> _stringCache = <String, Future<String>>{};
   final Map<String, Future<dynamic>> _structuredDataCache = <String, Future<dynamic>>{};
+  final Map<String, Future<dynamic>> _structuredBinaryDataCache = <String, Future<dynamic>>{};
 
   @override
   Future<String> loadString(String key, { bool cache = true }) {
@@ -221,16 +242,66 @@
     return completer.future;
   }
 
+  /// Retrieve bytedata from the asset bundle, parse it with the given function,
+  /// and return the function's result.
+  ///
+  /// The result of parsing the bytedata is cached (the bytedata itself is not).
+  /// For any given `key`, the `parser` is only run the first time.
+  ///
+  /// Once the value has been parsed, the future returned by this function for
+  /// subsequent calls will be a [SynchronousFuture], which resolves its
+  /// callback synchronously.
+  @override
+  Future<T> loadStructuredBinaryData<T>(String key, FutureOr<T> Function(ByteData data) parser) {
+    if (_structuredBinaryDataCache.containsKey(key)) {
+      return _structuredBinaryDataCache[key]! as Future<T>;
+    }
+
+    // load can return a SynchronousFuture in certain cases, like in the
+    // flutter_test framework. So, we need to support both async and sync flows.
+    Completer<T>? completer; // For async flow.
+    SynchronousFuture<T>? result; // For sync flow.
+
+    load(key)
+      .then<T>(parser)
+      .then<void>((T value) {
+        result = SynchronousFuture<T>(value);
+        if (completer != null) {
+          // The load and parse operation ran asynchronously. We already returned
+          // from the loadStructuredBinaryData function and therefore the caller
+          // was given the future of the completer.
+          completer.complete(value);
+        }
+      }, onError: (Object error, StackTrace stack) {
+        completer!.completeError(error, stack);
+      });
+
+    if (result != null) {
+      // The above code ran synchronously. We can synchronously return the result.
+      _structuredBinaryDataCache[key] = result!;
+      return result!;
+    }
+
+    // Since the above code is being run asynchronously and thus hasn't run its
+    // `then` handler yet, we'll return a completer that will be completed
+    // when the handler does run.
+    completer = Completer<T>();
+    _structuredBinaryDataCache[key] = completer.future;
+    return completer.future;
+  }
+
   @override
   void evict(String key) {
     _stringCache.remove(key);
     _structuredDataCache.remove(key);
+    _structuredBinaryDataCache.remove(key);
   }
 
   @override
   void clear() {
     _stringCache.clear();
     _structuredDataCache.clear();
+    _structuredBinaryDataCache.clear();
   }
 
   @override
@@ -272,7 +343,7 @@
     bool debugUsePlatformChannel = false;
     assert(() {
       // dart:io is safe to use here since we early return for web
-      // above. If that code is changed, this needs to be gaurded on
+      // above. If that code is changed, this needs to be guarded on
       // web presence. Override how assets are loaded in tests so that
       // the old loader behavior that allows tests to load assets from
       // the current package using the package prefix.
diff --git a/packages/flutter/lib/src/services/asset_manifest.dart b/packages/flutter/lib/src/services/asset_manifest.dart
new file mode 100644
index 0000000..cddf798
--- /dev/null
+++ b/packages/flutter/lib/src/services/asset_manifest.dart
@@ -0,0 +1,134 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/foundation.dart';
+
+import 'asset_bundle.dart';
+import 'message_codecs.dart';
+
+const String _kAssetManifestFilename = 'AssetManifest.bin';
+
+/// Contains details about available assets and their variants.
+/// See [Asset variants](https://docs.flutter.dev/development/ui/assets-and-images#asset-variants)
+/// to learn about asset variants and how to declare them.
+abstract class AssetManifest {
+  /// Loads asset manifest data from an [AssetBundle] object and creates an
+  /// [AssetManifest] object from that data.
+  static Future<AssetManifest> loadFromAssetBundle(AssetBundle bundle) {
+    return bundle.loadStructuredBinaryData(_kAssetManifestFilename, _AssetManifestBin.fromStandardMessageCodecMessage);
+  }
+
+  /// Lists the keys of all main assets. This does not include assets
+  /// that are variants of other assets.
+  ///
+  /// The logical key maps to the path of an asset specified in the pubspec.yaml
+  /// file at build time.
+  ///
+  /// See [Specifying assets](https://docs.flutter.dev/development/ui/assets-and-images#specifying-assets)
+  /// and [Loading assets](https://docs.flutter.dev/development/ui/assets-and-images#loading-assets) for more
+  /// information.
+  List<String> listAssets();
+
+  /// Retrieves metadata about an asset and its variants.
+  ///
+  /// Note that this method considers a main asset to be a variant of itself and
+  /// includes it in the returned list.
+  ///
+  /// Throws an [ArgumentError] if [key] cannot be found within the manifest. To
+  /// avoid this, use a key obtained from the [listAssets] method.
+  List<AssetMetadata> getAssetVariants(String key);
+}
+
+// Lazily parses the binary asset manifest into a data structure that's easier to work
+// with.
+//
+// The binary asset manifest is a map of asset keys to a list of objects
+// representing the asset's variants.
+//
+// The entries with each variant object are:
+//  - "asset": the location of this variant to load it from.
+//  - "dpr": The device-pixel-ratio that the asset is best-suited for.
+//
+// New fields could be added to this object schema to support new asset variation
+// features, such as themes, locale/region support, reading directions, and so on.
+class _AssetManifestBin implements AssetManifest {
+  _AssetManifestBin(Map<Object?, Object?> standardMessageData): _data = standardMessageData;
+
+  factory _AssetManifestBin.fromStandardMessageCodecMessage(ByteData message) {
+    final dynamic data = const StandardMessageCodec().decodeMessage(message);
+    return _AssetManifestBin(data as Map<Object?, Object?>);
+  }
+
+  final Map<Object?, Object?> _data;
+  final Map<String, List<AssetMetadata>> _typeCastedData = <String, List<AssetMetadata>>{};
+
+  @override
+  List<AssetMetadata> getAssetVariants(String key) {
+    // We lazily delay typecasting to prevent a performance hiccup when parsing
+    // large asset manifests. This is important to keep an app's first asset
+    // load fast.
+    if (!_typeCastedData.containsKey(key)) {
+      final Object? variantData = _data[key];
+      if (variantData == null) {
+        throw ArgumentError('Asset key $key was not found within the asset manifest.');
+      }
+      _typeCastedData[key] = ((_data[key] ?? <Object?>[]) as Iterable<Object?>)
+        .cast<Map<Object?, Object?>>()
+        .map((Map<Object?, Object?> data) => AssetMetadata(
+            key: data['asset']! as String,
+            targetDevicePixelRatio: data['dpr']! as double,
+            main: false,
+        ))
+        .toList();
+
+      _data.remove(key);
+    }
+
+    final AssetMetadata mainAsset = AssetMetadata(key: key,
+      targetDevicePixelRatio: null,
+      main: true
+    );
+
+    return <AssetMetadata>[mainAsset, ..._typeCastedData[key]!];
+  }
+
+  @override
+  List<String> listAssets() {
+    return <String>[..._data.keys.cast<String>(), ..._typeCastedData.keys];
+  }
+}
+
+/// Contains information about an asset.
+@immutable
+class AssetMetadata {
+  /// Creates an object containing information about an asset.
+  const AssetMetadata({
+    required this.key,
+    required this.targetDevicePixelRatio,
+    required this.main,
+  });
+
+  /// The device pixel ratio that this asset is most ideal for. This is determined
+  /// by the name of the parent folder of the asset file. For example, if the
+  /// parent folder is named "3.0x", the target device pixel ratio of that
+  /// asset will be interpreted as 3.
+  ///
+  /// This will be null if the parent folder name is not a ratio value followed
+  /// by an "x".
+  ///
+  /// See [Declaring resolution-aware image assets](https://docs.flutter.dev/development/ui/assets-and-images#resolution-aware)
+  /// for more information.
+  final double? targetDevicePixelRatio;
+
+  /// The asset's key, which is the path to the asset specified in the pubspec.yaml
+  /// file at build time.
+  final String key;
+
+  /// Whether or not this is a main asset. In other words, this is true if
+  /// this asset is not a variant of another asset.
+  ///
+  /// See [Asset variants](https://docs.flutter.dev/development/ui/assets-and-images#asset-variants)
+  /// for more about asset variants.
+  final bool main;
+}
diff --git a/packages/flutter/test/services/asset_bundle_test.dart b/packages/flutter/test/services/asset_bundle_test.dart
index 8a97df3..ef00683 100644
--- a/packages/flutter/test/services/asset_bundle_test.dart
+++ b/packages/flutter/test/services/asset_bundle_test.dart
@@ -14,16 +14,28 @@
 
   @override
   Future<ByteData> load(String key) async {
-    loadCallCount[key] = loadCallCount[key] ?? 0 + 1;
+    loadCallCount[key] = (loadCallCount[key] ?? 0) + 1;
     if (key == 'AssetManifest.json') {
       return ByteData.view(Uint8List.fromList(const Utf8Encoder().convert('{"one": ["one"]}')).buffer);
     }
 
+    if (key == 'AssetManifest.bin') {
+      return const StandardMessageCodec().encodeMessage(<String, Object>{
+        'one': <Object>[]
+      })!;
+    }
+
+    if (key == 'counter') {
+      return ByteData.view(Uint8List.fromList(const Utf8Encoder().convert(loadCallCount[key]!.toString())).buffer);
+    }
+
     if (key == 'one') {
       return ByteData(1)..setInt8(0, 49);
     }
+
     throw FlutterError('key not found');
   }
+
 }
 
 void main() {
@@ -40,7 +52,7 @@
     final String assetString = await bundle.loadString('one');
     expect(assetString, equals('1'));
 
-    expect(bundle.loadCallCount['one'], 1);
+    expect(bundle.loadCallCount['one'], 2);
 
     late Object loadException;
     try {
@@ -101,4 +113,69 @@
       ),
     );
   }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56314
+
+  test('CachingAssetBundle caches results for loadString, loadStructuredData, and loadBinaryStructuredData', () async {
+    final TestAssetBundle bundle = TestAssetBundle();
+
+    final String firstLoadStringResult = await bundle.loadString('counter');
+    final String secondLoadStringResult = await bundle.loadString('counter');
+    expect(firstLoadStringResult, '1');
+    expect(secondLoadStringResult, '1');
+
+    final String firstLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('one'));
+    final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('two'));
+    expect(firstLoadStructuredDataResult, 'one');
+    expect(secondLoadStructuredDataResult, 'one');
+
+    final String firstLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('one'));
+    final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('two'));
+    expect(firstLoadStructuredBinaryDataResult, 'one');
+    expect(secondLoadStructuredBinaryDataResult, 'one');
+  });
+
+  test("CachingAssetBundle.clear clears all cached values'", () async {
+    final TestAssetBundle bundle = TestAssetBundle();
+
+    await bundle.loadString('counter');
+    bundle.clear();
+    final String secondLoadStringResult = await bundle.loadString('counter');
+    expect(secondLoadStringResult, '2');
+
+    await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('one'));
+    bundle.clear();
+    final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('two'));
+    expect(secondLoadStructuredDataResult, 'two');
+
+    await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('one'));
+    bundle.clear();
+    final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('two'));
+    expect(secondLoadStructuredBinaryDataResult, 'two');
+  });
+
+  test('CachingAssetBundle.evict evicts a particular key from the cache', () async {
+    final TestAssetBundle bundle = TestAssetBundle();
+
+    await bundle.loadString('counter');
+    bundle.evict('counter');
+    final String secondLoadStringResult = await bundle.loadString('counter');
+    expect(secondLoadStringResult, '2');
+
+    await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('one'));
+    bundle.evict('AssetManifest.json');
+    final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('two'));
+    expect(secondLoadStructuredDataResult, 'two');
+
+    await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('one'));
+    bundle.evict('AssetManifest.bin');
+    final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('two'));
+    expect(secondLoadStructuredBinaryDataResult, 'two');
+  });
+
+  test('loadStructuredBinaryData correctly loads ByteData', () async {
+    final TestAssetBundle bundle = TestAssetBundle();
+    final Map<Object?, Object?> assetManifest =
+      await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData data) => const StandardMessageCodec().decodeMessage(data) as Map<Object?, Object?>);
+    expect(assetManifest.keys.toList(), equals(<String>['one']));
+    expect(assetManifest['one'], <Object>[]);
+  });
 }
diff --git a/packages/flutter/test/services/asset_manifest_test.dart b/packages/flutter/test/services/asset_manifest_test.dart
new file mode 100644
index 0000000..4bd9c92
--- /dev/null
+++ b/packages/flutter/test/services/asset_manifest_test.dart
@@ -0,0 +1,68 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class TestAssetBundle extends AssetBundle {
+  @override
+  Future<ByteData> load(String key) async {
+    if (key == 'AssetManifest.bin') {
+      final Map<String, List<Object>> binManifestData = <String, List<Object>>{
+        'assets/foo.png': <Object>[
+          <String, Object>{
+            'asset': 'assets/2x/foo.png',
+            'dpr': 2.0
+          }
+        ],
+        'assets/bar.png': <Object>[],
+      };
+
+      final ByteData data = const StandardMessageCodec().encodeMessage(binManifestData)!;
+      return data;
+    }
+
+    throw ArgumentError('Unexpected key');
+  }
+
+  @override
+  Future<T> loadStructuredData<T>(String key, Future<T> Function(String value) parser) async {
+    return parser(await loadString(key));
+  }
+}
+
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  test('loadFromBundle correctly parses a binary asset manifest', () async {
+    final AssetManifest manifest = await AssetManifest.loadFromAssetBundle(TestAssetBundle());
+
+    expect(manifest.listAssets(), unorderedEquals(<String>['assets/foo.png', 'assets/bar.png']));
+
+    final List<AssetMetadata> fooVariants = manifest.getAssetVariants('assets/foo.png');
+    expect(fooVariants.length, 2);
+    final AssetMetadata firstFooVariant = fooVariants[0];
+    expect(firstFooVariant.key, 'assets/foo.png');
+    expect(firstFooVariant.targetDevicePixelRatio, null);
+    expect(firstFooVariant.main, true);
+    final AssetMetadata secondFooVariant = fooVariants[1];
+    expect(secondFooVariant.key, 'assets/2x/foo.png');
+    expect(secondFooVariant.targetDevicePixelRatio, 2.0);
+    expect(secondFooVariant.main, false);
+
+    final List<AssetMetadata> barVariants = manifest.getAssetVariants('assets/bar.png');
+    expect(barVariants.length, 1);
+    final AssetMetadata firstBarVariant = barVariants[0];
+    expect(firstBarVariant.key, 'assets/bar.png');
+    expect(firstBarVariant.targetDevicePixelRatio, null);
+    expect(firstBarVariant.main, true);
+  });
+
+  test('getAssetVariants throws if given a key not contained in the asset manifest', () async {
+    final AssetManifest manifest = await AssetManifest.loadFromAssetBundle(TestAssetBundle());
+
+    expect(() => manifest.getAssetVariants('invalid asset key'), throwsArgumentError);
+  });
+}