[cross_file] Use Blobs to store files on the web. (#494)

diff --git a/packages/cross_file/CHANGELOG.md b/packages/cross_file/CHANGELOG.md
index 819a96e..edf7631 100644
--- a/packages/cross_file/CHANGELOG.md
+++ b/packages/cross_file/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.3.2
+
+* Improve web implementation so it can stream larger files.
+
 ## 0.3.1+5
 
 * Unify XFile interface for web and mobile platforms
diff --git a/packages/cross_file/README.md b/packages/cross_file/README.md
index 15f3da9..e82ad95 100644
--- a/packages/cross_file/README.md
+++ b/packages/cross_file/README.md
@@ -2,10 +2,10 @@
 
 An abstraction to allow working with files across multiple platforms.
 
-# Usage
+## Usage
 
-Import `package:cross_file/cross_file.dart`, instantiate a `CrossFile` 
-using a path or byte array and use its methods and properties to 
+Import `package:cross_file/cross_file.dart`, instantiate a `XFile`
+using a path or byte array and use its methods and properties to
 access the file and its metadata.
 
 Example:
@@ -25,3 +25,21 @@
 ```
 
 You will find links to the API docs on the [pub page](https://pub.dev/packages/cross_file).
+
+## Web Limitations
+
+`XFile` on the web platform is backed by [Blob](https://api.dart.dev/be/180361/dart-html/Blob-class.html)
+objects and their URLs.
+
+It seems that Safari hangs when reading Blobs larger than 4GB (your app will stop
+without returning any data, or throwing an exception).
+
+This package will attempt to throw an `Exception` before a large file is accessed
+from Safari (if its size is known beforehand), so that case can be handled
+programmatically.
+
+### Browser compatibility
+
+[![Data on Global support for Blob constructing](https://caniuse.bitsofco.de/image/blobbuilder.png)](https://caniuse.com/blobbuilder)
+
+[![Data on Global support for Blob URLs](https://caniuse.bitsofco.de/image/bloburls.png)](https://caniuse.com/bloburls)
diff --git a/packages/cross_file/lib/src/types/html.dart b/packages/cross_file/lib/src/types/html.dart
index 9ed7425..cb3473b 100644
--- a/packages/cross_file/lib/src/types/html.dart
+++ b/packages/cross_file/lib/src/types/html.dart
@@ -2,6 +2,7 @@
 // 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:convert';
 import 'dart:html';
 import 'dart:typed_data';
@@ -11,6 +12,9 @@
 import './base.dart';
 import '../web_helpers/web_helpers.dart';
 
+// Four Gigabytes, in bytes.
+const int _fourGigabytes = 4 * 1024 * 1024 * 1024;
+
 /// A CrossFile that works on web.
 ///
 /// It wraps the bytes of a selected file.
@@ -20,8 +24,8 @@
   /// Optionally, this can be initialized with `bytes` and `length`
   /// so no http requests are performed to retrieve files later.
   ///
-  /// `name` needs to be passed from the outside, since we only have
-  /// access to it while we create the ObjectUrl.
+  /// `name` needs to be passed from the outside, since it's only available
+  /// while handling [html.File]s (when the ObjectUrl is created).
   XFile(
     String path, {
     String? mimeType,
@@ -32,12 +36,16 @@
     @visibleForTesting CrossFileTestOverrides? overrides,
   })  : _mimeType = mimeType,
         _path = path,
-        _data = bytes,
         _length = length,
         _overrides = overrides,
         _lastModified = lastModified ?? DateTime.fromMillisecondsSinceEpoch(0),
         _name = name ?? '',
-        super(path);
+        super(path) {
+    // Cache `bytes` as Blob, if passed.
+    if (bytes != null) {
+      _browserBlob = _createBlobFromBytes(bytes, mimeType);
+    }
+  }
 
   /// Construct an CrossFile from its data
   XFile.fromData(
@@ -49,22 +57,56 @@
     String? path,
     @visibleForTesting CrossFileTestOverrides? overrides,
   })  : _mimeType = mimeType,
-        _data = bytes,
         _length = length,
         _overrides = overrides,
         _lastModified = lastModified ?? DateTime.fromMillisecondsSinceEpoch(0),
         _name = name ?? '',
         super(path) {
     if (path == null) {
-      final Blob blob = (mimeType == null)
-          ? Blob(<dynamic>[bytes])
-          : Blob(<dynamic>[bytes], mimeType);
-      _path = Url.createObjectUrl(blob);
+      _browserBlob = _createBlobFromBytes(bytes, mimeType);
+      _path = Url.createObjectUrl(_browserBlob);
     } else {
       _path = path;
     }
   }
 
+  // Initializes a Blob from a bunch of `bytes` and an optional `mimeType`.
+  Blob _createBlobFromBytes(Uint8List bytes, String? mimeType) {
+    return (mimeType == null)
+        ? Blob(<dynamic>[bytes])
+        : Blob(<dynamic>[bytes], mimeType);
+  }
+
+  // Overridable (meta) data that can be specified by the constructors.
+
+  // MimeType of the file (eg: "image/gif").
+  final String? _mimeType;
+  // Name (with extension) of the file (eg: "anim.gif")
+  final String _name;
+  // Path of the file (must be a valid Blob URL, when set manually!)
+  late String _path;
+  // The size of the file (in bytes).
+  final int? _length;
+  // The time the file was last modified.
+  final DateTime _lastModified;
+
+  // The link to the binary object in the browser memory (Blob).
+  // This can be passed in (as `bytes` in the constructor) or derived from
+  // [_path] with a fetch request.
+  // (Similar to a (read-only) dart:io File.)
+  Blob? _browserBlob;
+
+  // An html Element that will be used to trigger a "save as" dialog later.
+  // TODO(dit): https://github.com/flutter/flutter/issues/91400 Remove this _target.
+  late Element _target;
+
+  // Overrides for testing
+  // TODO(dit): https://github.com/flutter/flutter/issues/91400 Remove these _overrides,
+  // they're only used to Save As...
+  final CrossFileTestOverrides? _overrides;
+
+  bool get _hasTestOverrides => _overrides != null;
+
   @override
   String? get mimeType => _mimeType;
 
@@ -74,58 +116,86 @@
   @override
   String get path => _path;
 
-  final String? _mimeType;
-  final String _name;
-  late String _path;
-  final Uint8List? _data;
-  final int? _length;
-  final DateTime? _lastModified;
-
-  late Element _target;
-
-  final CrossFileTestOverrides? _overrides;
-
-  bool get _hasTestOverrides => _overrides != null;
-
   @override
-  Future<DateTime> lastModified() async =>
-      Future<DateTime>.value(_lastModified);
+  Future<DateTime> lastModified() async => _lastModified;
 
-  Future<Uint8List> get _bytes async {
-    if (_data != null) {
-      return Future<Uint8List>.value(UnmodifiableUint8ListView(_data!));
+  Future<Blob> get _blob async {
+    if (_browserBlob != null) {
+      return _browserBlob!;
     }
 
-    // We can force 'response' to be a byte buffer by passing responseType:
-    final ByteBuffer? response =
-        (await HttpRequest.request(path, responseType: 'arraybuffer')).response;
+    // Attempt to re-hydrate the blob from the `path` via a (local) HttpRequest.
+    // Note that safari hangs if the Blob is >=4GB, so bail out in that case.
+    if (isSafari() && _length != null && _length! >= _fourGigabytes) {
+      throw Exception('Safari cannot handle XFiles larger than 4GB.');
+    }
 
-    return response?.asUint8List() ?? Uint8List(0);
+    late HttpRequest request;
+    try {
+      request = await HttpRequest.request(path, responseType: 'blob');
+    } on ProgressEvent catch (e) {
+      if (e.type == 'error') {
+        throw Exception(
+            'Could not load Blob from its URL. Has it been revoked?');
+      }
+      rethrow;
+    }
+
+    _browserBlob = request.response;
+
+    assert(_browserBlob != null, 'The Blob backing this XFile cannot be null!');
+
+    return _browserBlob!;
   }
 
   @override
-  Future<int> length() async => _length ?? (await _bytes).length;
+  Future<Uint8List> readAsBytes() async {
+    return _blob.then(_blobToByteBuffer);
+  }
+
+  @override
+  Future<int> length() async => _length ?? (await _blob).size;
 
   @override
   Future<String> readAsString({Encoding encoding = utf8}) async {
-    return encoding.decode(await _bytes);
+    return readAsBytes().then(encoding.decode);
   }
 
-  @override
-  Future<Uint8List> readAsBytes() async =>
-      Future<Uint8List>.value(await _bytes);
-
+  // TODO(dit): https://github.com/flutter/flutter/issues/91867 Implement openRead properly.
   @override
   Stream<Uint8List> openRead([int? start, int? end]) async* {
-    final Uint8List bytes = await _bytes;
-    yield bytes.sublist(start ?? 0, end ?? bytes.length);
+    final Blob blob = await _blob;
+
+    final Blob slice = blob.slice(start ?? 0, end ?? blob.size, blob.type);
+
+    final Uint8List convertedSlice = await _blobToByteBuffer(slice);
+
+    yield convertedSlice;
+  }
+
+  // Converts an html Blob object to a Uint8List, through a FileReader.
+  Future<Uint8List> _blobToByteBuffer(Blob blob) async {
+    final FileReader reader = FileReader();
+    reader.readAsArrayBuffer(blob);
+
+    await reader.onLoadEnd.first;
+
+    final Uint8List? result = reader.result as Uint8List?;
+
+    if (result == null) {
+      throw Exception('Cannot read bytes from Blob. Is it still available?');
+    }
+
+    return result;
   }
 
   /// Saves the data of this CrossFile at the location indicated by path.
   /// For the web implementation, the path variable is ignored.
+  // TODO(dit): https://github.com/flutter/flutter/issues/91400
+  // Move implementation to web_helpers.dart
   @override
   Future<void> saveTo(String path) async {
-    // Create a DOM container where we can host the anchor.
+    // Create a DOM container where the anchor can be injected.
     _target = ensureInitialized('__x_file_dom_element');
 
     // Create an <a> tag with the appropriate download attributes and click it
@@ -134,13 +204,15 @@
         ? _overrides!.createAnchorElement(this.path, name) as AnchorElement
         : createAnchorElement(this.path, name);
 
-    // Clear the children in our container so we can add an element to click
+    // Clear the children in _target and add an element to click
     _target.children.clear();
     addElementToContainerAndClick(_target, element);
   }
 }
 
 /// Overrides some functions to allow testing
+// TODO(dit): https://github.com/flutter/flutter/issues/91400
+// Move this to web_helpers_test.dart
 @visibleForTesting
 class CrossFileTestOverrides {
   /// Default constructor for overrides
diff --git a/packages/cross_file/lib/src/web_helpers/web_helpers.dart b/packages/cross_file/lib/src/web_helpers/web_helpers.dart
index bc7136f..da023b6 100644
--- a/packages/cross_file/lib/src/web_helpers/web_helpers.dart
+++ b/packages/cross_file/lib/src/web_helpers/web_helpers.dart
@@ -25,7 +25,7 @@
   element.click();
 }
 
-/// Initializes a DOM container where we can host elements.
+/// Initializes a DOM container where elements can be injected.
 Element ensureInitialized(String id) {
   Element? target = querySelector('#$id');
   if (target == null) {
@@ -36,3 +36,9 @@
   }
   return target;
 }
+
+/// Determines if the browser is Safari from its vendor string.
+/// (This is the same check used in flutter/engine)
+bool isSafari() {
+  return window.navigator.vendor == 'Apple Computer, Inc.';
+}
diff --git a/packages/cross_file/pubspec.yaml b/packages/cross_file/pubspec.yaml
index 41cea10..beb5c45 100644
--- a/packages/cross_file/pubspec.yaml
+++ b/packages/cross_file/pubspec.yaml
@@ -2,7 +2,7 @@
 description: An abstraction to allow working with files across multiple platforms.
 repository: https://github.com/flutter/packages/tree/master/packages/cross_file
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+cross_file%22
-version: 0.3.1+5
+version: 0.3.2
 
 environment:
   sdk: ">=2.12.0 <3.0.0"
@@ -11,6 +11,7 @@
 dependencies:
   flutter:
     sdk: flutter
+  js: ^0.6.3
   meta: ^1.3.0
 
 dev_dependencies:
diff --git a/packages/cross_file/test/x_file_html_test.dart b/packages/cross_file/test/x_file_html_test.dart
index c214672..8bfb605 100644
--- a/packages/cross_file/test/x_file_html_test.dart
+++ b/packages/cross_file/test/x_file_html_test.dart
@@ -10,8 +10,9 @@
 
 import 'package:cross_file/cross_file.dart';
 import 'package:flutter_test/flutter_test.dart';
+import 'package:js/js_util.dart' as js_util;
 
-const String expectedStringContents = 'Hello, world!';
+const String expectedStringContents = 'Hello, world! I ❤ ñ! 空手';
 final Uint8List bytes = Uint8List.fromList(utf8.encode(expectedStringContents));
 final html.File textFile = html.File(<Object>[bytes], 'hello.txt');
 final String textFileUrl = html.Url.createObjectUrl(textFile);
@@ -23,6 +24,7 @@
     test('Can be read as a string', () async {
       expect(await file.readAsString(), equals(expectedStringContents));
     });
+
     test('Can be read as bytes', () async {
       expect(await file.readAsBytes(), equals(bytes));
     });
@@ -42,6 +44,7 @@
     test('Can be read as a string', () async {
       expect(await file.readAsString(), equals(expectedStringContents));
     });
+
     test('Can be read as bytes', () async {
       expect(await file.readAsBytes(), equals(bytes));
     });
@@ -55,6 +58,28 @@
     });
   });
 
+  group('Blob backend', () {
+    final XFile file = XFile(textFileUrl);
+
+    test('Stores data as a Blob', () async {
+      // Read the blob from its path 'natively'
+      final Object response = await html.window.fetch(file.path);
+      // Call '.arrayBuffer()' on the fetch response object to look at its bytes.
+      final ByteBuffer data = await js_util.promiseToFuture(
+        js_util.callMethod(response, 'arrayBuffer', <Object?>[]),
+      );
+      expect(data.asUint8List(), equals(bytes));
+    });
+
+    test('Data may be purged from the blob!', () async {
+      html.Url.revokeObjectUrl(file.path);
+
+      expect(() async {
+        await file.readAsBytes();
+      }, throwsException);
+    });
+  });
+
   group('saveTo(..)', () {
     const String crossFileDomElementId = '__x_file_dom_element';