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,
+];