Merge pull request #348 from stuartmorgan/import-flutter-image

[flutter_image] Import from flutter/flutter_image
diff --git a/.cirrus.yml b/.cirrus.yml
index d284ee0..7df1c8e 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -39,7 +39,8 @@
       depends_on:
         - format+analyze
     - name: test
-      script: ./script/tool_runner.sh test
+      # Exclude flutter_image; its tests need a test server, so are run via local_tests.sh
+      script: ./script/tool_runner.sh test --exclude=flutter_image
       depends_on:
         - format+analyze
     - name: build-apks+java-test
diff --git a/README.md b/README.md
index 7b91f41..ca1e26b 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@
 | [css\_colors](./packages/css_colors/) | [![pub package](https://img.shields.io/pub/v/css_colors.svg)](https://pub.dev/packages/css_colors) |
 | [extension\_google\_sign\_in\_as\_googleapis\_auth](./packages/extension_google_sign_in_as_googleapis_auth/) | [![pub package](https://img.shields.io/pub/v/extension_google_sign_in_as_googleapis_auth.svg)](https://pub.dev/packages/extension_google_sign_in_as_googleapis_auth) |
 | [fuchsia\_ctl](./packages/fuchsia_ctl/) | [![pub package](https://img.shields.io/pub/v/fuchsia_ctl.svg)](https://pub.dev/packages/fuchsia_ctl) |
+| [flutter\_image](./packages/flutter_image/) | [![pub package](https://img.shields.io/pub/v/flutter_image.svg)](https://pub.dev/packages/flutter_image) |
 | [flutter\_lints](./packages/flutter_lints/) | [![pub package](https://img.shields.io/pub/v/flutter_lints.svg)](https://pub.dev/packages/flutter_lints) |
 | [flutter\_markdown](./packages/flutter_markdown/) | [![pub package](https://img.shields.io/pub/v/flutter_markdown.svg)](https://pub.dev/packages/flutter_markdown) |
 | [multicast\_dns](./packages/multicast_dns/) | [![pub package](https://img.shields.io/pub/v/multicast_dns.svg)](https://pub.dev/packages/multicast_dns) |
diff --git a/packages/flutter_image/.gitignore b/packages/flutter_image/.gitignore
new file mode 100644
index 0000000..b063423
--- /dev/null
+++ b/packages/flutter_image/.gitignore
@@ -0,0 +1,11 @@
+.buildlog
+.DS_Store
+.idea/libraries/*
+.idea/vcs.xml
+.idea/workspace.xml
+.pub/
+.settings/
+build/
+packages
+.packages
+pubspec.lock
diff --git a/packages/flutter_image/.idea/flutter_image.iml b/packages/flutter_image/.idea/flutter_image.iml
new file mode 100644
index 0000000..8e57b7f
--- /dev/null
+++ b/packages/flutter_image/.idea/flutter_image.iml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="FLUTTER_MODULE_TYPE" version="4">
+  <component name="NewModuleRootManager" inherit-compiler-output="true">
+    <exclude-output />
+    <content url="file://$MODULE_DIR$">
+      <excludeFolder url="file://$MODULE_DIR$/.pub" />
+      <excludeFolder url="file://$MODULE_DIR$/build" />
+      <excludeFolder url="file://$MODULE_DIR$/packages" />
+      <excludeFolder url="file://$MODULE_DIR$/test/packages" />
+      <excludeFolder url="file://$MODULE_DIR$/tool/packages" />
+    </content>
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+    <orderEntry type="library" name="Dart SDK" level="project" />
+    <orderEntry type="library" name="Dart Packages" level="project" />
+  </component>
+</module>
\ No newline at end of file
diff --git a/packages/flutter_image/.idea/modules.xml b/packages/flutter_image/.idea/modules.xml
new file mode 100644
index 0000000..8cab81c
--- /dev/null
+++ b/packages/flutter_image/.idea/modules.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/flutter_image.iml" filepath="$PROJECT_DIR$/.idea/flutter_image.iml" />
+    </modules>
+  </component>
+</project>
\ No newline at end of file
diff --git a/packages/flutter_image/.travis.yml b/packages/flutter_image/.travis.yml
new file mode 100644
index 0000000..b63a1d6
--- /dev/null
+++ b/packages/flutter_image/.travis.yml
@@ -0,0 +1,24 @@
+os:
+  - linux
+sudo: false
+
+addons:
+  apt:
+    # Flutter depends on /usr/lib/x86_64-linux-gnu/libstdc++.so.6 version GLIBCXX_3.4.18
+    sources:
+      - ubuntu-toolchain-r-test # if we don't specify this, the libstdc++6 we get is the wrong version
+    packages:
+      - libstdc++6
+      - fonts-droid-fallback
+
+before_script:
+  - git clone https://github.com/flutter/flutter.git -b master
+  - export PATH=$PATH:$(pwd)/flutter/bin
+  - export FLUTTER_HOME=$(pwd)/flutter
+  - flutter doctor
+
+script: ./tool/travis.sh
+
+cache:
+  directories:
+    - $HOME/.pub-cache
diff --git a/packages/flutter_image/AUTHORS b/packages/flutter_image/AUTHORS
new file mode 100644
index 0000000..e8063a8
--- /dev/null
+++ b/packages/flutter_image/AUTHORS
@@ -0,0 +1,6 @@
+# Below is a list of people and organizations that have contributed
+# to the project. Names should be added to the list like so:
+#
+#   Name/Organization <email address>
+
+Google Inc.
diff --git a/packages/flutter_image/CHANGELOG.md b/packages/flutter_image/CHANGELOG.md
new file mode 100644
index 0000000..c6972b2
--- /dev/null
+++ b/packages/flutter_image/CHANGELOG.md
@@ -0,0 +1,37 @@
+## 4.0.1
+
+- Moved source to flutter/packages
+
+## 4.0.0
+
+- Migrates to null safety
+- **Breaking change**: `NetworkImageWithRetry.load` now throws a `FetchFailure` if the fetched image data is zero bytes.
+
+## 3.0.0
+
+* **Breaking change**. Updates for Flutter 1.10.15.
+
+## 2.0.1
+
+- Update Flutter SDK version constraint.
+
+## 2.0.0
+
+* **Breaking change**. Updates for Flutter 1.5.9.
+
+## 1.0.0
+
+* **Breaking change**. SDK constraints to support Flutter beta versions and Dart 2 only.
+
+## 0.0.3
+
+- Moved `flutter_test` to dev_dependencies in `pubspec.yaml`, and fixed issues
+flagged by the analyzer.
+
+## 0.0.2
+
+- Add `NetworkImageWithRetry`, an `ImageProvider` with a retry mechanism.
+
+## 0.0.1
+
+- Contains no useful code.
diff --git a/packages/flutter_image/LICENSE b/packages/flutter_image/LICENSE
new file mode 100644
index 0000000..c6823b8
--- /dev/null
+++ b/packages/flutter_image/LICENSE
@@ -0,0 +1,25 @@
+Copyright 2013 The Flutter Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google Inc. nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/packages/flutter_image/README.md b/packages/flutter_image/README.md
new file mode 100644
index 0000000..f31adcd
--- /dev/null
+++ b/packages/flutter_image/README.md
@@ -0,0 +1,22 @@
+# Image utilities for Flutter
+
+## NetworkImageWithRetry
+
+Use `NetworkImageWithRetry` instead of `Image.network` to load images from the
+network with a retry mechanism.
+
+Example:
+
+```dart
+var avatar = new Image(
+  image: new NetworkImageWithRetry('http://example.com/avatars/123.jpg'),
+);
+```
+
+The retry mechanism may be customized by supplying a custom `FetchStrategy`
+function. `FetchStrategyBuilder` is a utility class that helps building fetch
+strategy functions.
+
+## Features and bugs
+
+Please file feature requests and bugs at https://github.com/flutter/flutter/issues.
diff --git a/packages/flutter_image/lib/flutter_image.dart b/packages/flutter_image/lib/flutter_image.dart
new file mode 100644
index 0000000..2100c0e
--- /dev/null
+++ b/packages/flutter_image/lib/flutter_image.dart
@@ -0,0 +1,5 @@
+// Copyright 2013 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.
+
+export 'network.dart';
diff --git a/packages/flutter_image/lib/network.dart b/packages/flutter_image/lib/network.dart
new file mode 100644
index 0000000..220a661
--- /dev/null
+++ b/packages/flutter_image/lib/network.dart
@@ -0,0 +1,437 @@
+// Copyright 2013 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.
+
+/// Utilities for loading images from the network.
+///
+/// This library expands the capabilities of the basic [Image.network] and
+/// [NetworkImage] provided by Flutter core libraries, to include a retry
+/// mechanism and connectivity detection.
+library network;
+
+import 'dart:async';
+import 'dart:io' as io;
+import 'dart:math' as math;
+import 'dart:typed_data';
+import 'dart:ui' as ui;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+/// Fetches the image from the given URL, associating it with the given scale.
+///
+/// If [fetchStrategy] is specified, uses it instead of the
+/// [defaultFetchStrategy] to obtain instructions for fetching the URL.
+///
+/// The image will be cached regardless of cache headers from the server.
+@immutable
+class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
+  /// Creates an object that fetches the image at the given [url].
+  ///
+  /// The arguments must not be null.
+  const NetworkImageWithRetry(
+    this.url, {
+    this.scale = 1.0,
+    this.fetchStrategy = defaultFetchStrategy,
+  });
+
+  /// The HTTP client used to download images.
+  static final io.HttpClient _client = io.HttpClient();
+
+  /// The URL from which the image will be fetched.
+  final String url;
+
+  /// The scale to place in the [ImageInfo] object of the image.
+  final double scale;
+
+  /// The strategy used to fetch the [url] and retry when the fetch fails.
+  ///
+  /// This function is called at least once and may be called multiple times.
+  /// The first time it is called, it is passed a null [FetchFailure], which
+  /// indicates that this is the first attempt to fetch the [url]. Subsequent
+  /// calls pass non-null [FetchFailure] values, which indicate that previous
+  /// fetch attempts failed.
+  final FetchStrategy fetchStrategy;
+
+  /// Used by [defaultFetchStrategy].
+  ///
+  /// This indirection is necessary because [defaultFetchStrategy] is used as
+  /// the default constructor argument value, which requires that it be a const
+  /// expression.
+  static final FetchStrategy _defaultFetchStrategyFunction =
+      const FetchStrategyBuilder().build();
+
+  /// The [FetchStrategy] that [NetworkImageWithRetry] uses by default.
+  static Future<FetchInstructions> defaultFetchStrategy(
+      Uri uri, FetchFailure? failure) {
+    return _defaultFetchStrategyFunction(uri, failure);
+  }
+
+  @override
+  Future<NetworkImageWithRetry> obtainKey(ImageConfiguration configuration) {
+    return SynchronousFuture<NetworkImageWithRetry>(this);
+  }
+
+  @override
+  ImageStreamCompleter load(NetworkImageWithRetry key, DecoderCallback decode) {
+    return OneFrameImageStreamCompleter(_loadWithRetry(key, decode),
+        informationCollector: () sync* {
+      yield ErrorDescription('Image provider: $this');
+      yield ErrorDescription('Image key: $key');
+    });
+  }
+
+  void _debugCheckInstructions(FetchInstructions? instructions) {
+    assert(() {
+      if (instructions == null) {
+        if (fetchStrategy == defaultFetchStrategy) {
+          throw StateError(
+              'The default FetchStrategy returned null FetchInstructions. This\n'
+              'is likely a bug in $runtimeType. Please file a bug at\n'
+              'https://github.com/flutter/flutter/issues.');
+        } else {
+          throw StateError(
+              'The custom FetchStrategy used to fetch $url returned null\n'
+              'FetchInstructions. FetchInstructions must never be null, but\n'
+              'instead instruct to either make another fetch attempt or give up.');
+        }
+      }
+      return true;
+    }());
+  }
+
+  Future<ImageInfo> _loadWithRetry(
+      NetworkImageWithRetry key, DecoderCallback decode) async {
+    assert(key == this);
+
+    final Stopwatch stopwatch = Stopwatch()..start();
+    final Uri resolved = Uri.base.resolve(key.url);
+    FetchInstructions instructions = await fetchStrategy(resolved, null);
+    _debugCheckInstructions(instructions);
+    int attemptCount = 0;
+    FetchFailure? lastFailure;
+
+    while (!instructions.shouldGiveUp) {
+      attemptCount += 1;
+      io.HttpClientRequest? request;
+      try {
+        request = await _client
+            .getUrl(instructions.uri)
+            .timeout(instructions.timeout);
+        final io.HttpClientResponse response =
+            await request.close().timeout(instructions.timeout);
+
+        if (response.statusCode != 200) {
+          throw FetchFailure._(
+            totalDuration: stopwatch.elapsed,
+            attemptCount: attemptCount,
+            httpStatusCode: response.statusCode,
+          );
+        }
+
+        final _Uint8ListBuilder builder = await response
+            .fold(
+              _Uint8ListBuilder(),
+              (_Uint8ListBuilder buffer, List<int> bytes) => buffer..add(bytes),
+            )
+            .timeout(instructions.timeout);
+
+        final Uint8List bytes = builder.data;
+
+        if (bytes.lengthInBytes == 0) {
+          throw FetchFailure._(
+            totalDuration: stopwatch.elapsed,
+            attemptCount: attemptCount,
+            httpStatusCode: response.statusCode,
+          );
+        }
+
+        final ui.Codec codec = await decode(bytes);
+        final ui.Image image = (await codec.getNextFrame()).image;
+        return ImageInfo(
+          image: image,
+          scale: key.scale,
+        );
+      } catch (error) {
+        request?.close();
+        lastFailure = error is FetchFailure
+            ? error
+            : FetchFailure._(
+                totalDuration: stopwatch.elapsed,
+                attemptCount: attemptCount,
+                originalException: error,
+              );
+        instructions = await fetchStrategy(instructions.uri, lastFailure);
+        _debugCheckInstructions(instructions);
+      }
+    }
+
+    if (instructions.alternativeImage != null) {
+      return instructions.alternativeImage!;
+    }
+
+    assert(lastFailure != null);
+
+    if (FlutterError.onError != null) {
+      FlutterError.onError!(FlutterErrorDetails(
+        exception: lastFailure!,
+        library: 'package:flutter_image',
+        context:
+            ErrorDescription('$runtimeType failed to load ${instructions.uri}'),
+      ));
+    }
+
+    throw lastFailure!;
+  }
+
+  @override
+  bool operator ==(dynamic other) {
+    if (other.runtimeType != runtimeType) {
+      return false;
+    }
+    final NetworkImageWithRetry typedOther = other;
+    return url == typedOther.url && scale == typedOther.scale;
+  }
+
+  @override
+  int get hashCode => hashValues(url, scale);
+
+  @override
+  String toString() => '$runtimeType("$url", scale: $scale)';
+}
+
+/// This function is called to get [FetchInstructions] to fetch an image.
+///
+/// The instructions are executed as soon as possible after the returned
+/// [Future] resolves. If a delay in necessary between retries, use a delayed
+/// [Future], such as [Future.delayed]. This is useful for implementing
+/// back-off strategies and for recovering from lack of connectivity.
+///
+/// [uri] is the last requested image URI. A [FetchStrategy] may choose to use
+/// a different URI (see [FetchInstructions.uri]).
+///
+/// If [failure] is `null`, then this is the first attempt to fetch the image.
+///
+/// If the [failure] is not `null`, it contains the information about the
+/// previous attempt to fetch the image. A [FetchStrategy] may attempt to
+/// recover from the failure by returning [FetchInstructions] that instruct
+/// [NetworkImageWithRetry] to try again.
+///
+/// See [NetworkImageWithRetry.defaultFetchStrategy] for an example.
+typedef FetchStrategy = Future<FetchInstructions> Function(
+    Uri uri, FetchFailure? failure);
+
+/// Instructions [NetworkImageWithRetry] uses to fetch the image.
+@immutable
+class FetchInstructions {
+  /// Instructs [NetworkImageWithRetry] to give up trying to download the image.
+  const FetchInstructions.giveUp({
+    required this.uri,
+    this.alternativeImage,
+  })  : shouldGiveUp = true,
+        timeout = Duration.zero;
+
+  /// Instructs [NetworkImageWithRetry] to attempt to download the image from
+  /// the given [uri] and [timeout] if it takes too long.
+  const FetchInstructions.attempt({
+    required this.uri,
+    required this.timeout,
+  })  : shouldGiveUp = false,
+        alternativeImage = null;
+
+  /// Instructs to give up trying.
+  ///
+  /// If [alternativeImage] is `null` reports the latest [FetchFailure] to
+  /// [FlutterError].
+  final bool shouldGiveUp;
+
+  /// Timeout for the next network call.
+  final Duration timeout;
+
+  /// The URI to use on the next attempt.
+  final Uri uri;
+
+  /// Instructs to give up and use this image instead.
+  final Future<ImageInfo>? alternativeImage;
+
+  @override
+  String toString() {
+    return '$runtimeType(\n'
+        '  shouldGiveUp: $shouldGiveUp\n'
+        '  timeout: $timeout\n'
+        '  uri: $uri\n'
+        '  alternativeImage?: ${alternativeImage != null ? 'yes' : 'no'}\n'
+        ')';
+  }
+}
+
+/// Contains information about a failed attempt to fetch an image.
+@immutable
+class FetchFailure implements Exception {
+  const FetchFailure._({
+    required this.totalDuration,
+    required this.attemptCount,
+    this.httpStatusCode,
+    this.originalException,
+  }) : assert(attemptCount > 0);
+
+  /// The total amount of time it has taken so far to download the image.
+  final Duration totalDuration;
+
+  /// The number of times [NetworkImageWithRetry] attempted to fetch the image
+  /// so far.
+  ///
+  /// This value starts with 1 and grows by 1 with each attempt to fetch the
+  /// image.
+  final int attemptCount;
+
+  /// HTTP status code, such as 500.
+  final int? httpStatusCode;
+
+  /// The exception that caused the fetch failure.
+  final dynamic originalException;
+
+  @override
+  String toString() {
+    return '$runtimeType(\n'
+        '  attemptCount: $attemptCount\n'
+        '  httpStatusCode: $httpStatusCode\n'
+        '  totalDuration: $totalDuration\n'
+        '  originalException: $originalException\n'
+        ')';
+  }
+}
+
+/// An indefinitely growing builder of a [Uint8List].
+class _Uint8ListBuilder {
+  static const int _kInitialSize = 100000; // 100KB-ish
+
+  int _usedLength = 0;
+  Uint8List _buffer = Uint8List(_kInitialSize);
+
+  Uint8List get data => Uint8List.view(_buffer.buffer, 0, _usedLength);
+
+  void add(List<int> bytes) {
+    _ensureCanAdd(bytes.length);
+    _buffer.setAll(_usedLength, bytes);
+    _usedLength += bytes.length;
+  }
+
+  void _ensureCanAdd(int byteCount) {
+    final int totalSpaceNeeded = _usedLength + byteCount;
+
+    int newLength = _buffer.length;
+    while (totalSpaceNeeded > newLength) {
+      newLength *= 2;
+    }
+
+    if (newLength != _buffer.length) {
+      final Uint8List newBuffer = Uint8List(newLength);
+      newBuffer.setAll(0, _buffer);
+      newBuffer.setRange(0, _usedLength, _buffer);
+      _buffer = newBuffer;
+    }
+  }
+}
+
+/// Determines whether the given HTTP [statusCode] is transient.
+typedef TransientHttpStatusCodePredicate = bool Function(int statusCode);
+
+/// Builds a [FetchStrategy] function that retries up to a certain amount of
+/// times for up to a certain amount of time.
+///
+/// Pauses between retries with pauses growing exponentially (known as
+/// exponential backoff). Each attempt is subject to a [timeout]. Retries only
+/// those HTTP status codes considered transient by a
+/// [transientHttpStatusCodePredicate] function.
+class FetchStrategyBuilder {
+  /// Creates a fetch strategy builder.
+  ///
+  /// All parameters must be non-null.
+  const FetchStrategyBuilder({
+    this.timeout = const Duration(seconds: 30),
+    this.totalFetchTimeout = const Duration(minutes: 1),
+    this.maxAttempts = 5,
+    this.initialPauseBetweenRetries = const Duration(seconds: 1),
+    this.exponentialBackoffMultiplier = 2,
+    this.transientHttpStatusCodePredicate =
+        defaultTransientHttpStatusCodePredicate,
+  });
+
+  /// A list of HTTP status codes that can generally be retried.
+  ///
+  /// You may want to use a different list depending on the needs of your
+  /// application.
+  static const List<int> defaultTransientHttpStatusCodes = <int>[
+    0, // Network error
+    408, // Request timeout
+    500, // Internal server error
+    502, // Bad gateway
+    503, // Service unavailable
+    504 // Gateway timeout
+  ];
+
+  /// Maximum amount of time a single fetch attempt is allowed to take.
+  final Duration timeout;
+
+  /// A strategy built by this builder will retry for up to this amount of time
+  /// before giving up.
+  final Duration totalFetchTimeout;
+
+  /// Maximum number of attempts a strategy will make before giving up.
+  final int maxAttempts;
+
+  /// Initial amount of time between retries.
+  final Duration initialPauseBetweenRetries;
+
+  /// The pause between retries is multiplied by this number with each attempt,
+  /// causing it to grow exponentially.
+  final num exponentialBackoffMultiplier;
+
+  /// A function that determines whether a given HTTP status code should be
+  /// retried.
+  final TransientHttpStatusCodePredicate transientHttpStatusCodePredicate;
+
+  /// Uses [defaultTransientHttpStatusCodes] to determine if the [statusCode] is
+  /// transient.
+  static bool defaultTransientHttpStatusCodePredicate(int statusCode) {
+    return defaultTransientHttpStatusCodes.contains(statusCode);
+  }
+
+  /// Builds a [FetchStrategy] that operates using the properties of this
+  /// builder.
+  FetchStrategy build() {
+    return (Uri uri, FetchFailure? failure) async {
+      if (failure == null) {
+        // First attempt. Just load.
+        return FetchInstructions.attempt(
+          uri: uri,
+          timeout: timeout,
+        );
+      }
+
+      final bool isRetriableFailure = (failure.httpStatusCode != null &&
+              transientHttpStatusCodePredicate(failure.httpStatusCode!)) ||
+          failure.originalException is io.SocketException;
+
+      // If cannot retry, give up.
+      if (!isRetriableFailure || // retrying will not help
+          failure.totalDuration > totalFetchTimeout || // taking too long
+          failure.attemptCount > maxAttempts) {
+        // too many attempts
+        return FetchInstructions.giveUp(uri: uri);
+      }
+
+      // Exponential back-off.
+      final Duration pauseBetweenRetries = initialPauseBetweenRetries *
+          math.pow(exponentialBackoffMultiplier, failure.attemptCount - 1);
+      await Future<void>.delayed(pauseBetweenRetries);
+
+      // Retry.
+      return FetchInstructions.attempt(
+        uri: uri,
+        timeout: timeout,
+      );
+    };
+  }
+}
diff --git a/packages/flutter_image/pubspec.yaml b/packages/flutter_image/pubspec.yaml
new file mode 100644
index 0000000..433a6aa
--- /dev/null
+++ b/packages/flutter_image/pubspec.yaml
@@ -0,0 +1,19 @@
+name: flutter_image
+description: >
+  Image utilities for Flutter: providers, effects, etc
+repository: https://github.com/flutter/packages/tree/master/packages/flutter_image
+version: 4.0.1
+
+dependencies:
+  flutter:
+    sdk: flutter
+
+dev_dependencies:
+  flutter_test:
+    sdk: flutter
+  quiver: ^3.0.0
+  test: any
+
+environment:
+  sdk: ">=2.12.0 <3.0.0"
+  flutter: ">=1.10.15-pre.144"
diff --git a/packages/flutter_image/run_tests.sh b/packages/flutter_image/run_tests.sh
new file mode 100755
index 0000000..d8fdf75
--- /dev/null
+++ b/packages/flutter_image/run_tests.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+# Copyright 2013 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.
+
+# Fast fail the script on failures.
+set -e
+# Print commands to stdout
+set -x
+
+flutter packages get
+flutter analyze lib/ test/
+
+dart test/network_test_server.dart &
+SERVER_PID=$!
+sleep 2
+
+flutter test
+kill $SERVER_PID
diff --git a/packages/flutter_image/test/network_test.dart b/packages/flutter_image/test/network_test.dart
new file mode 100644
index 0000000..85cd5f3
--- /dev/null
+++ b/packages/flutter_image/test/network_test.dart
@@ -0,0 +1,152 @@
+// Copyright 2013 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:async';
+import 'dart:io' show HttpOverrides;
+import 'package:flutter/foundation.dart';
+import 'package:flutter/painting.dart';
+import 'package:flutter_image/network.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:quiver/testing/async.dart';
+
+String _imageUrl(String fileName) {
+  return 'http://localhost:11111/$fileName';
+}
+
+void main() {
+  AutomatedTestWidgetsFlutterBinding();
+  HttpOverrides.global = null;
+
+  group('NetworkImageWithRetry', () {
+    group('succeeds', () {
+      setUp(() {
+        FlutterError.onError = (FlutterErrorDetails error) {
+          fail('$error');
+        };
+      });
+
+      tearDown(() {
+        FlutterError.onError = FlutterError.dumpErrorToConsole;
+      });
+
+      test('loads image from network', () async {
+        final NetworkImageWithRetry subject = NetworkImageWithRetry(
+          _imageUrl('immediate_success.png'),
+        );
+
+        assertThatImageLoadingSucceeds(subject);
+      });
+
+      test('succeeds on successful retry', () async {
+        final NetworkImageWithRetry subject = NetworkImageWithRetry(
+          _imageUrl('error.png'),
+          fetchStrategy: (Uri uri, FetchFailure? failure) async {
+            if (failure == null) {
+              return FetchInstructions.attempt(
+                uri: uri,
+                timeout: const Duration(minutes: 1),
+              );
+            } else {
+              expect(failure.attemptCount, lessThan(2));
+              return FetchInstructions.attempt(
+                uri: Uri.parse(_imageUrl('immediate_success.png')),
+                timeout: const Duration(minutes: 1),
+              );
+            }
+          },
+        );
+        assertThatImageLoadingSucceeds(subject);
+      });
+    });
+
+    group('fails', () {
+      final List<FlutterErrorDetails> errorLog = <FlutterErrorDetails>[];
+      FakeAsync fakeAsync = FakeAsync();
+
+      setUp(() {
+        FlutterError.onError = errorLog.add;
+      });
+
+      tearDown(() {
+        fakeAsync = FakeAsync();
+        errorLog.clear();
+        FlutterError.onError = FlutterError.dumpErrorToConsole;
+      });
+
+      test('retries 6 times then gives up', () async {
+        final dynamic maxAttemptCountReached = expectAsync0(() {});
+
+        int attemptCount = 0;
+        Future<void> onAttempt() async {
+          expect(attemptCount, lessThan(7));
+          if (attemptCount == 6) {
+            maxAttemptCountReached();
+          }
+          await Future<void>.delayed(Duration.zero);
+          fakeAsync.elapse(const Duration(seconds: 60));
+          attemptCount++;
+        }
+
+        final NetworkImageWithRetry subject = NetworkImageWithRetry(
+          _imageUrl('error.png'),
+          fetchStrategy: (Uri uri, FetchFailure? failure) {
+            Timer.run(onAttempt);
+            return fakeAsync.run((FakeAsync fakeAsync) {
+              return NetworkImageWithRetry.defaultFetchStrategy(uri, failure);
+            });
+          },
+        );
+
+        assertThatImageLoadingFails(subject, errorLog);
+      });
+
+      test('gives up immediately on non-retriable errors (HTTP 404)', () async {
+        int attemptCount = 0;
+        Future<void> onAttempt() async {
+          expect(attemptCount, lessThan(2));
+          await Future<void>.delayed(Duration.zero);
+          fakeAsync.elapse(const Duration(seconds: 60));
+          attemptCount++;
+        }
+
+        final NetworkImageWithRetry subject = NetworkImageWithRetry(
+          _imageUrl('does_not_exist.png'),
+          fetchStrategy: (Uri uri, FetchFailure? failure) {
+            Timer.run(onAttempt);
+            return fakeAsync.run((FakeAsync fakeAsync) {
+              return NetworkImageWithRetry.defaultFetchStrategy(uri, failure);
+            });
+          },
+        );
+
+        assertThatImageLoadingFails(subject, errorLog);
+      });
+    });
+  });
+}
+
+void assertThatImageLoadingFails(
+    NetworkImageWithRetry subject, List<FlutterErrorDetails> errorLog) {
+  subject
+      .load(subject, PaintingBinding.instance!.instantiateImageCodec)
+      .addListener(ImageStreamListener(
+        (ImageInfo image, bool synchronousCall) {},
+        onError: expectAsync2((Object error, StackTrace? _) {
+          expect(errorLog.single.exception, isInstanceOf<FetchFailure>());
+          expect(error, isInstanceOf<FetchFailure>());
+          expect(error, equals(errorLog.single.exception));
+        }),
+      ));
+}
+
+void assertThatImageLoadingSucceeds(NetworkImageWithRetry subject) {
+  subject
+      .load(subject, PaintingBinding.instance!.instantiateImageCodec)
+      .addListener(
+    ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) {
+      expect(image.image.height, 1);
+      expect(image.image.width, 1);
+    })),
+  );
+}
diff --git a/packages/flutter_image/test/network_test_server.dart b/packages/flutter_image/test/network_test_server.dart
new file mode 100644
index 0000000..467cd04
--- /dev/null
+++ b/packages/flutter_image/test/network_test_server.dart
@@ -0,0 +1,91 @@
+// Copyright 2013 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:async';
+import 'dart:io';
+
+const int _kTestServerPort = 11111;
+
+Future<void> main() async {
+  final HttpServer testServer =
+      await HttpServer.bind(InternetAddress.loopbackIPv4, _kTestServerPort);
+  await for (final HttpRequest request in testServer) {
+    if (request.uri.path.endsWith('/immediate_success.png')) {
+      request.response.add(_kTransparentImage);
+    } else if (request.uri.path.endsWith('/error.png')) {
+      request.response.statusCode = 500;
+    } else {
+      request.response.statusCode = 404;
+    }
+    await request.response.flush();
+    await request.response.close();
+  }
+}
+
+const List<int> _kTransparentImage = <int>[
+  0x89,
+  0x50,
+  0x4E,
+  0x47,
+  0x0D,
+  0x0A,
+  0x1A,
+  0x0A,
+  0x00,
+  0x00,
+  0x00,
+  0x0D,
+  0x49,
+  0x48,
+  0x44,
+  0x52,
+  0x00,
+  0x00,
+  0x00,
+  0x01,
+  0x00,
+  0x00,
+  0x00,
+  0x01,
+  0x08,
+  0x06,
+  0x00,
+  0x00,
+  0x00,
+  0x1F,
+  0x15,
+  0xC4,
+  0x89,
+  0x00,
+  0x00,
+  0x00,
+  0x0A,
+  0x49,
+  0x44,
+  0x41,
+  0x54,
+  0x78,
+  0x9C,
+  0x63,
+  0x00,
+  0x01,
+  0x00,
+  0x00,
+  0x05,
+  0x00,
+  0x01,
+  0x0D,
+  0x0A,
+  0x2D,
+  0xB4,
+  0x00,
+  0x00,
+  0x00,
+  0x00,
+  0x49,
+  0x45,
+  0x4E,
+  0x44,
+  0xAE,
+];