diff --git a/.cirrus.yml b/.cirrus.yml index d284ee02b6..7df1c8e283 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -39,7 +39,8 @@ task: 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 7b91f41089..ca1e26b955 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ These are the available packages in this repository. | [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 0000000000..b063423aad --- /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 0000000000..8e57b7f5a9 --- /dev/null +++ b/packages/flutter_image/.idea/flutter_image.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ 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 0000000000..8cab81cac9 --- /dev/null +++ b/packages/flutter_image/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ 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 0000000000..b63a1d627c --- /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 0000000000..e8063a8cd6 --- /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 + +Google Inc. diff --git a/packages/flutter_image/CHANGELOG.md b/packages/flutter_image/CHANGELOG.md new file mode 100644 index 0000000000..c6972b29c6 --- /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 0000000000..c6823b81eb --- /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 0000000000..f31adcd98f --- /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 0000000000..2100c0e288 --- /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 0000000000..220a66138b --- /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 { + /// 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 defaultFetchStrategy( + Uri uri, FetchFailure? failure) { + return _defaultFetchStrategyFunction(uri, failure); + } + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(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 _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 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 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? 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 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 defaultTransientHttpStatusCodes = [ + 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.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 0000000000..433a6aa726 --- /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 0000000000..d8fdf75ae0 --- /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 0000000000..85cd5f397c --- /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 errorLog = []; + 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 onAttempt() async { + expect(attemptCount, lessThan(7)); + if (attemptCount == 6) { + maxAttemptCountReached(); + } + await Future.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 onAttempt() async { + expect(attemptCount, lessThan(2)); + await Future.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 errorLog) { + subject + .load(subject, PaintingBinding.instance!.instantiateImageCodec) + .addListener(ImageStreamListener( + (ImageInfo image, bool synchronousCall) {}, + onError: expectAsync2((Object error, StackTrace? _) { + expect(errorLog.single.exception, isInstanceOf()); + expect(error, isInstanceOf()); + 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 0000000000..467cd04263 --- /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 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 _kTransparentImage = [ + 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, +];