[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';