| // 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; |
| // TODO(a14n): remove this import once Flutter 3.1 or later reaches stable (including flutter/flutter#104231) |
| // ignore: unnecessary_import |
| 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]. |
| const NetworkImageWithRetry( |
| this.url, { |
| this.scale = 1.0, |
| this.fetchStrategy = defaultFetchStrategy, |
| this.headers, |
| }); |
| |
| /// 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; |
| |
| /// HTTP Headers applied to the request. |
| /// |
| /// Keys from this map will be used as header field names and the |
| /// values will be used as header values. A list of header names can |
| /// be found at https://datatracker.ietf.org/doc/html/rfc7231#section-8.3 |
| /// |
| /// If the value is a DateTime, an HTTP date format will be applied. |
| /// If the value is an Iterable, each element will be added separately. |
| /// For all other types the default Object.toString method will be used. |
| /// |
| /// Header names are converted to lower-case. If two header names are |
| /// the same when converted to lower-case, they are considered to be |
| /// the same header, with one set of values. |
| /// |
| /// For example, to add an authorization header to the request: |
| /// |
| /// final NetworkImageWithRetry subject = NetworkImageWithRetry( |
| /// Uri.parse('https://www.flutter.com/top_secret.png'), |
| /// headers: <String, Object>{ |
| /// 'Authorization': base64Encode(utf8.encode('user:password')) |
| /// }, |
| /// ); |
| final Map<String, Object>? headers; |
| |
| /// 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 |
| // TODO(cyanglaz): migrate to use the new APIs |
| // https://github.com/flutter/flutter/issues/105336 |
| // ignore: deprecated_member_use |
| 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( |
| // TODO(cyanglaz): migrate to use the new APIs |
| // https://github.com/flutter/flutter/issues/105336 |
| // ignore: deprecated_member_use |
| NetworkImageWithRetry key, |
| // ignore: deprecated_member_use |
| 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); |
| |
| headers?.forEach((String key, Object value) { |
| request?.headers.add(key, value); |
| }); |
| |
| 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; |
| } |
| return other is NetworkImageWithRetry && |
| url == other.url && |
| scale == other.scale; |
| } |
| |
| @override |
| int get hashCode => Object.hash(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, |
| ); |
| }; |
| } |
| } |