Migrates to null safety (#22)

diff --git a/packages/flutter_image/CHANGELOG.md b/packages/flutter_image/CHANGELOG.md
index cfb10e5..6d9ac91 100644
--- a/packages/flutter_image/CHANGELOG.md
+++ b/packages/flutter_image/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## 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.
diff --git a/packages/flutter_image/lib/network.dart b/packages/flutter_image/lib/network.dart
index 3f91fcb..41db7c3 100644
--- a/packages/flutter_image/lib/network.dart
+++ b/packages/flutter_image/lib/network.dart
@@ -29,10 +29,11 @@
   /// 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 })
-      : assert(url != null),
-        assert(scale != null),
-        assert(fetchStrategy != 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();
@@ -57,10 +58,12 @@
   /// 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();
+  static final FetchStrategy _defaultFetchStrategyFunction =
+      const FetchStrategyBuilder().build();
 
   /// The [FetchStrategy] that [NetworkImageWithRetry] uses by default.
-  static Future<FetchInstructions> defaultFetchStrategy(Uri uri, FetchFailure failure) {
+  static Future<FetchInstructions> defaultFetchStrategy(
+      Uri uri, FetchFailure? failure) {
     return _defaultFetchStrategyFunction(uri, failure);
   }
 
@@ -71,37 +74,34 @@
 
   @override
   ImageStreamCompleter load(NetworkImageWithRetry key, DecoderCallback decode) {
-    return OneFrameImageStreamCompleter(
-        _loadWithRetry(key, decode),
+    return OneFrameImageStreamCompleter(_loadWithRetry(key, decode),
         informationCollector: () sync* {
-          yield ErrorDescription('Image provider: $this');
-          yield ErrorDescription('Image key: $key');
-        }
-    );
+      yield ErrorDescription('Image provider: $this');
+      yield ErrorDescription('Image key: $key');
+    });
   }
 
-  void _debugCheckInstructions(FetchInstructions instructions) {
+  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.'
-          );
+              '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.'
-          );
+              '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 {
+  Future<ImageInfo> _loadWithRetry(
+      NetworkImageWithRetry key, DecoderCallback decode) async {
     assert(key == this);
 
     final Stopwatch stopwatch = Stopwatch()..start();
@@ -109,16 +109,19 @@
     FetchInstructions instructions = await fetchStrategy(resolved, null);
     _debugCheckInstructions(instructions);
     int attemptCount = 0;
-    FetchFailure lastFailure;
+    FetchFailure? lastFailure;
 
     while (!instructions.shouldGiveUp) {
       attemptCount += 1;
-      io.HttpClientRequest request;
+      io.HttpClientRequest? request;
       try {
-        request = await _client.getUrl(instructions.uri).timeout(instructions.timeout);
-        final io.HttpClientResponse response = await request.close().timeout(instructions.timeout);
+        request = await _client
+            .getUrl(instructions.uri)
+            .timeout(instructions.timeout);
+        final io.HttpClientResponse response =
+            await request.close().timeout(instructions.timeout);
 
-        if (response == null || response.statusCode != 200) {
+        if (response.statusCode != 200) {
           throw FetchFailure._(
             totalDuration: stopwatch.elapsed,
             attemptCount: attemptCount,
@@ -126,21 +129,25 @@
           );
         }
 
-        final _Uint8ListBuilder builder = await response.fold(
-          _Uint8ListBuilder(),
+        final _Uint8ListBuilder builder = await response
+            .fold(
+              _Uint8ListBuilder(),
               (_Uint8ListBuilder buffer, List<int> bytes) => buffer..add(bytes),
-        ).timeout(instructions.timeout);
+            )
+            .timeout(instructions.timeout);
 
         final Uint8List bytes = builder.data;
 
-        if (bytes.lengthInBytes == 0)
-          return null;
+        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;
-        if (image == null)
-          return null;
-
         return ImageInfo(
           image: image,
           scale: key.scale,
@@ -159,27 +166,31 @@
       }
     }
 
-    if (instructions.alternativeImage != null)
-      return instructions.alternativeImage;
+    if (instructions.alternativeImage != null) {
+      return instructions.alternativeImage!;
+    }
 
     assert(lastFailure != null);
 
-    FlutterError.onError(FlutterErrorDetails(
-      exception: lastFailure,
-      library: 'package:flutter_image',
-      context: ErrorDescription('$runtimeType failed to load ${instructions.uri}'),
-    ));
+    if (FlutterError.onError != null) {
+      FlutterError.onError!(FlutterErrorDetails(
+        exception: lastFailure!,
+        library: 'package:flutter_image',
+        context:
+            ErrorDescription('$runtimeType failed to load ${instructions.uri}'),
+      ));
+    }
 
-    return null;
+    throw lastFailure!;
   }
 
   @override
   bool operator ==(dynamic other) {
-    if (other.runtimeType != runtimeType)
+    if (other.runtimeType != runtimeType) {
       return false;
+    }
     final NetworkImageWithRetry typedOther = other;
-    return url == typedOther.url
-        && scale == typedOther.scale;
+    return url == typedOther.url && scale == typedOther.scale;
   }
 
   @override
@@ -207,26 +218,26 @@
 /// [NetworkImageWithRetry] to try again.
 ///
 /// See [NetworkImageWithRetry.defaultFetchStrategy] for an example.
-typedef FetchStrategy = Future<FetchInstructions> Function(Uri uri, FetchFailure failure);
+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,
+    required this.uri,
     this.alternativeImage,
-  })
-      : shouldGiveUp = true,
-        timeout = null;
+  })  : 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;
+    required this.uri,
+    required this.timeout,
+  })   : shouldGiveUp = false,
+        alternativeImage = null;
 
   /// Instructs to give up trying.
   ///
@@ -241,16 +252,16 @@
   final Uri uri;
 
   /// Instructs to give up and use this image instead.
-  final Future<ImageInfo> alternativeImage;
+  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'
-      ')';
+        '  shouldGiveUp: $shouldGiveUp\n'
+        '  timeout: $timeout\n'
+        '  uri: $uri\n'
+        '  alternativeImage?: ${alternativeImage != null ? 'yes' : 'no'}\n'
+        ')';
   }
 }
 
@@ -258,12 +269,11 @@
 @immutable
 class FetchFailure implements Exception {
   const FetchFailure._({
-    @required this.totalDuration,
-    @required this.attemptCount,
+    required this.totalDuration,
+    required this.attemptCount,
     this.httpStatusCode,
     this.originalException,
-  }) : assert(totalDuration != null),
-       assert(attemptCount > 0);
+  }) : assert(attemptCount > 0);
 
   /// The total amount of time it has taken so far to download the image.
   final Duration totalDuration;
@@ -276,7 +286,7 @@
   final int attemptCount;
 
   /// HTTP status code, such as 500.
-  final int httpStatusCode;
+  final int? httpStatusCode;
 
   /// The exception that caused the fetch failure.
   final dynamic originalException;
@@ -294,7 +304,7 @@
 
 /// An indefinitely growing builder of a [Uint8List].
 class _Uint8ListBuilder {
-  static const int _kInitialSize = 100000;  // 100KB-ish
+  static const int _kInitialSize = 100000; // 100KB-ish
 
   int _usedLength = 0;
   Uint8List _buffer = Uint8List(_kInitialSize);
@@ -344,20 +354,16 @@
     this.maxAttempts = 5,
     this.initialPauseBetweenRetries = const Duration(seconds: 1),
     this.exponentialBackoffMultiplier = 2,
-    this.transientHttpStatusCodePredicate = defaultTransientHttpStatusCodePredicate,
-  }) : assert(timeout != null),
-       assert(totalFetchTimeout != null),
-       assert(maxAttempts != null),
-       assert(initialPauseBetweenRetries != null),
-       assert(exponentialBackoffMultiplier != null),
-       assert(transientHttpStatusCodePredicate != null);
+    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
+    0, // Network error
     408, // Request timeout
     500, // Internal server error
     502, // Bad gateway
@@ -395,7 +401,7 @@
   /// Builds a [FetchStrategy] that operates using the properties of this
   /// builder.
   FetchStrategy build() {
-    return (Uri uri, FetchFailure failure) async {
+    return (Uri uri, FetchFailure? failure) async {
       if (failure == null) {
         // First attempt. Just load.
         return FetchInstructions.attempt(
@@ -404,18 +410,21 @@
         );
       }
 
-      final bool isRetriableFailure = transientHttpStatusCodePredicate(failure.httpStatusCode) ||
+      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
+      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);
+      final Duration pauseBetweenRetries = initialPauseBetweenRetries *
+          math.pow(exponentialBackoffMultiplier, failure.attemptCount - 1);
       await Future<void>.delayed(pauseBetweenRetries);
 
       // Retry.
diff --git a/packages/flutter_image/pubspec.yaml b/packages/flutter_image/pubspec.yaml
index f930dee..399114c 100644
--- a/packages/flutter_image/pubspec.yaml
+++ b/packages/flutter_image/pubspec.yaml
@@ -1,5 +1,5 @@
 name: flutter_image
-version: 3.0.0
+version: 4.0.0
 description: >
   Image utilities for Flutter: providers, effects, etc
 author: Flutter Team <flutter-dev@googlegroups.com>
@@ -12,9 +12,9 @@
 dev_dependencies:
   flutter_test:
     sdk: flutter
-  quiver: '>=0.24.0 <3.0.0'
+  quiver: ^3.0.0
   test: any
 
 environment:
-  sdk: ">=2.0.0-dev.28.0 <3.0.0"
+  sdk: ">=2.12.0 <3.0.0"
   flutter: ">=1.10.15-pre.144"
diff --git a/packages/flutter_image/test/network_test.dart b/packages/flutter_image/test/network_test.dart
index d9dd426..4652ac3 100644
--- a/packages/flutter_image/test/network_test.dart
+++ b/packages/flutter_image/test/network_test.dart
@@ -19,122 +19,134 @@
   HttpOverrides.global = null;
 
   group('NetworkImageWithRetry', () {
-    setUp(() {
-      FlutterError.onError = (FlutterErrorDetails error) {
-        fail('$error');
-      };
+    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);
+      });
     });
 
-    tearDown(() {
-      FlutterError.onError = FlutterError.dumpErrorToConsole;
-    });
-
-    test('loads image from network', () async {
-      final NetworkImageWithRetry subject = NetworkImageWithRetry(
-        _imageUrl('immediate_success.png'),
-      );
-
-      subject.load(subject, PaintingBinding.instance.instantiateImageCodec).addListener(
-        ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) {
-          expect(image.image.height, 1);
-          expect(image.image.width, 1);
-        })),
-      );
-    });
-
-    test('retries 6 times then gives up', () async {
+    group('fails', () {
       final List<FlutterErrorDetails> errorLog = <FlutterErrorDetails>[];
-      FlutterError.onError = errorLog.add;
+      FakeAsync fakeAsync = FakeAsync();
 
-      final FakeAsync fakeAsync = FakeAsync();
-      final dynamic maxAttemptCountReached = expectAsync0(() {});
+      setUp(() {
+        FlutterError.onError = errorLog.add;
+      });
 
-      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++;
-      }
+      tearDown(() {
+        fakeAsync = FakeAsync();
+        errorLog.clear();
+        FlutterError.onError = FlutterError.dumpErrorToConsole;
+      });
 
-      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);
-          });
-        },
-      );
+      test('retries 6 times then gives up', () async {
+        final dynamic maxAttemptCountReached = expectAsync0(() {});
 
-      subject.load(subject, PaintingBinding.instance.instantiateImageCodec).addListener(
-        ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) {
-          expect(errorLog.single.exception, isInstanceOf<FetchFailure>());
-          expect(image, null);
-        })),
-      );
-    });
-
-    test('gives up immediately on non-retriable errors (HTTP 404)', () async {
-      final List<FlutterErrorDetails> errorLog = <FlutterErrorDetails>[];
-      FlutterError.onError = errorLog.add;
-
-      final FakeAsync fakeAsync = FakeAsync();
-
-      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);
-          });
-        },
-      );
-
-      subject.load(subject, PaintingBinding.instance.instantiateImageCodec).addListener(
-        ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) {
-          expect(errorLog.single.exception, isInstanceOf<FetchFailure>());
-          expect(image, null);
-        })),
-      );
-    });
-
-    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),
-            );
+        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++;
+        }
 
-      subject.load(subject, PaintingBinding.instance.instantiateImageCodec).addListener(
-        ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) {
-          expect(image.image.height, 1);
-          expect(image.image.width, 1);
-        })),
-      );
+        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);
+    })),
+  );
+}