[cross_file] An abstraction to allow working with files across multiple platforms. (#3260)
* Initial version of x_file package
* Renamed from x_file to cross_file
* Add back x_file type to file_selector
* Fix formatting issues
* Update homepage and version
* Added README.md
* Added missing copyright
* Revert "Added missing copyright"
This reverts commit cf7e8d5f3810ae646669f584738502a8cc3c5ca1.
* Add missing copyright
Co-Authored-By: Jason Panelli <38673809+jasonpanelli@users.noreply.github.com>
* Renamed class implementation back to XFile
* Fix formatting issues
* Rename to cross_file
* Added code owners for cross_file package
Co-authored-by: Jason Panelli <38673809+jasonpanelli@users.noreply.github.com>
diff --git a/CODEOWNERS b/CODEOWNERS
index 160c0d5..5f6d83c 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -9,6 +9,7 @@
packages/battery/** @amirh @matthew-carroll
packages/camera/** @bparrishMines
packages/connectivity/** @cyanglaz @matthew-carroll
+packages/cross_file/** @ditman @mvanbeusekom
packages/device_info/** @matthew-carroll
packages/espresso/** @collinjackson @adazh
packages/file_selector/** @ditman
diff --git a/packages/cross_file/CHANGELOG.md b/packages/cross_file/CHANGELOG.md
new file mode 100644
index 0000000..3b5ae77
--- /dev/null
+++ b/packages/cross_file/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.1.0
+
+- Initial open-source release
\ No newline at end of file
diff --git a/packages/cross_file/LICENSE b/packages/cross_file/LICENSE
new file mode 100644
index 0000000..2c91f14
--- /dev/null
+++ b/packages/cross_file/LICENSE
@@ -0,0 +1,25 @@
+Copyright 2020 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.
\ No newline at end of file
diff --git a/packages/cross_file/README.md b/packages/cross_file/README.md
new file mode 100644
index 0000000..f1ab89b
--- /dev/null
+++ b/packages/cross_file/README.md
@@ -0,0 +1,34 @@
+# cross_file
+
+An abstraction to allow working with files across multiple platforms.
+
+# Usage
+
+Import `package:cross/cross_info.dart`, instantiate a `CrossFile`
+using a path or byte array and use its methods and properties to
+access the file and its metadata.
+
+Example:
+
+```dart
+import 'package:cross_file/cross_file.dart';
+
+final file = CrossFile('assets/hello.txt');
+
+print('File information:');
+print('- Path: ${file.path}');
+print('- Name: ${file.name}');
+print('- MIME type: ${file.mimeType}');
+
+final fileContent = await file.readAsString();
+print('Content of the file: ${fileContent}'); // e.g. "Moto G (4)"
+```
+
+You will find links to the API docs on the [pub page](https://pub.dartlang.org/packages/cross_file).
+
+## Getting Started
+
+For help getting started with Flutter, view our online
+[documentation](http://flutter.io/).
+
+For help on editing plugin code, view the [documentation](https://flutter.io/platform-plugins/#edit-code).
\ No newline at end of file
diff --git a/packages/cross_file/lib/cross_file.dart b/packages/cross_file/lib/cross_file.dart
new file mode 100644
index 0000000..a3e2873
--- /dev/null
+++ b/packages/cross_file/lib/cross_file.dart
@@ -0,0 +1,5 @@
+// Copyright 2018 The Chromium 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 'src/x_file.dart';
diff --git a/packages/cross_file/lib/src/types/base.dart b/packages/cross_file/lib/src/types/base.dart
new file mode 100644
index 0000000..6dc2d51
--- /dev/null
+++ b/packages/cross_file/lib/src/types/base.dart
@@ -0,0 +1,86 @@
+// Copyright 2018 The Chromium 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:convert';
+import 'dart:typed_data';
+
+/// The interface for a CrossFile.
+///
+/// A CrossFile is a container that wraps the path of a selected
+/// file by the user and (in some platforms, like web) the bytes
+/// with the contents of the file.
+///
+/// This class is a very limited subset of dart:io [File], so all
+/// the methods should seem familiar.
+abstract class XFileBase {
+ /// Construct a CrossFile
+ XFileBase(String path);
+
+ /// Save the CrossFile at the indicated file path.
+ void saveTo(String path) async {
+ throw UnimplementedError('saveTo has not been implemented.');
+ }
+
+ /// Get the path of the picked file.
+ ///
+ /// This should only be used as a backwards-compatibility clutch
+ /// for mobile apps, or cosmetic reasons only (to show the user
+ /// the path they've picked).
+ ///
+ /// Accessing the data contained in the picked file by its path
+ /// is platform-dependant (and won't work on web), so use the
+ /// byte getters in the CrossFile instance instead.
+ String get path {
+ throw UnimplementedError('.path has not been implemented.');
+ }
+
+ /// The name of the file as it was selected by the user in their device.
+ ///
+ /// Use only for cosmetic reasons, do not try to use this as a path.
+ String get name {
+ throw UnimplementedError('.name has not been implemented.');
+ }
+
+ /// For web, it may be necessary for a file to know its MIME type.
+ String get mimeType {
+ throw UnimplementedError('.mimeType has not been implemented.');
+ }
+
+ /// Get the length of the file. Returns a `Future<int>` that completes with the length in bytes.
+ Future<int> length() {
+ throw UnimplementedError('.length() has not been implemented.');
+ }
+
+ /// Synchronously read the entire file contents as a string using the given [Encoding].
+ ///
+ /// By default, `encoding` is [utf8].
+ ///
+ /// Throws Exception if the operation fails.
+ Future<String> readAsString({Encoding encoding = utf8}) {
+ throw UnimplementedError('readAsString() has not been implemented.');
+ }
+
+ /// Synchronously read the entire file contents as a list of bytes.
+ ///
+ /// Throws Exception if the operation fails.
+ Future<Uint8List> readAsBytes() {
+ throw UnimplementedError('readAsBytes() has not been implemented.');
+ }
+
+ /// Create a new independent [Stream] for the contents of this file.
+ ///
+ /// If `start` is present, the file will be read from byte-offset `start`. Otherwise from the beginning (index 0).
+ ///
+ /// If `end` is present, only up to byte-index `end` will be read. Otherwise, until end of file.
+ ///
+ /// In order to make sure that system resources are freed, the stream must be read to completion or the subscription on the stream must be cancelled.
+ Stream<Uint8List> openRead([int start, int end]) {
+ throw UnimplementedError('openRead() has not been implemented.');
+ }
+
+ /// Get the last-modified time for the CrossFile
+ Future<DateTime> lastModified() {
+ throw UnimplementedError('openRead() has not been implemented.');
+ }
+}
diff --git a/packages/cross_file/lib/src/types/html.dart b/packages/cross_file/lib/src/types/html.dart
new file mode 100644
index 0000000..269f2a8
--- /dev/null
+++ b/packages/cross_file/lib/src/types/html.dart
@@ -0,0 +1,136 @@
+// Copyright 2018 The Chromium 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:convert';
+import 'dart:typed_data';
+
+import 'package:http/http.dart' as http show readBytes;
+import 'package:meta/meta.dart';
+import 'dart:html';
+
+import '../web_helpers/web_helpers.dart';
+import './base.dart';
+
+/// A CrossFile that works on web.
+///
+/// It wraps the bytes of a selected file.
+class XFile extends XFileBase {
+ String path;
+
+ final String mimeType;
+ final Uint8List _data;
+ final int _length;
+ final String name;
+ final DateTime _lastModified;
+ Element _target;
+
+ final CrossFileTestOverrides _overrides;
+
+ bool get _hasTestOverrides => _overrides != null;
+
+ /// Construct a CrossFile object from its ObjectUrl.
+ ///
+ /// 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.
+ XFile(
+ this.path, {
+ this.mimeType,
+ this.name,
+ int length,
+ Uint8List bytes,
+ DateTime lastModified,
+ @visibleForTesting CrossFileTestOverrides overrides,
+ }) : _data = bytes,
+ _length = length,
+ _overrides = overrides,
+ _lastModified = lastModified,
+ super(path);
+
+ /// Construct an CrossFile from its data
+ XFile.fromData(
+ Uint8List bytes, {
+ this.mimeType,
+ this.name,
+ int length,
+ DateTime lastModified,
+ this.path,
+ @visibleForTesting CrossFileTestOverrides overrides,
+ }) : _data = bytes,
+ _length = length,
+ _overrides = overrides,
+ _lastModified = lastModified,
+ super(path) {
+ if (path == null) {
+ final blob = (mimeType == null) ? Blob([bytes]) : Blob([bytes], mimeType);
+ this.path = Url.createObjectUrl(blob);
+ }
+ }
+
+ @override
+ Future<DateTime> lastModified() async {
+ if (_lastModified != null) {
+ return Future.value(_lastModified);
+ }
+ return null;
+ }
+
+ Future<Uint8List> get _bytes async {
+ if (_data != null) {
+ return Future.value(UnmodifiableUint8ListView(_data));
+ }
+ return http.readBytes(path);
+ }
+
+ @override
+ Future<int> length() async {
+ return _length ?? (await _bytes).length;
+ }
+
+ @override
+ Future<String> readAsString({Encoding encoding = utf8}) async {
+ return encoding.decode(await _bytes);
+ }
+
+ @override
+ Future<Uint8List> readAsBytes() async {
+ return Future.value(await _bytes);
+ }
+
+ @override
+ Stream<Uint8List> openRead([int start, int end]) async* {
+ final bytes = await _bytes;
+ yield bytes.sublist(start ?? 0, end ?? bytes.length);
+ }
+
+ /// Saves the data of this CrossFile at the location indicated by path.
+ /// For the web implementation, the path variable is ignored.
+ void saveTo(String path) async {
+ // Create a DOM container where we can host the anchor.
+ _target = ensureInitialized('__x_file_dom_element');
+
+ // Create an <a> tag with the appropriate download attributes and click it
+ // May be overridden with CrossFileTestOverrides
+ final AnchorElement element =
+ (_hasTestOverrides && _overrides.createAnchorElement != null)
+ ? _overrides.createAnchorElement(this.path, this.name)
+ : createAnchorElement(this.path, this.name);
+
+ // Clear the children in our container so we can add an element to click
+ _target.children.clear();
+ addElementToContainerAndClick(_target, element);
+ }
+}
+
+/// Overrides some functions to allow testing
+@visibleForTesting
+class CrossFileTestOverrides {
+ /// For overriding the creation of the file input element.
+ Element Function(String href, String suggestedName) createAnchorElement;
+
+ /// Default constructor for overrides
+ CrossFileTestOverrides({this.createAnchorElement});
+}
diff --git a/packages/cross_file/lib/src/types/interface.dart b/packages/cross_file/lib/src/types/interface.dart
new file mode 100644
index 0000000..e30bc63
--- /dev/null
+++ b/packages/cross_file/lib/src/types/interface.dart
@@ -0,0 +1,58 @@
+// Copyright 2018 The Chromium 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:typed_data';
+import 'package:meta/meta.dart';
+
+import './base.dart';
+
+/// A CrossFile is a cross-platform, simplified File abstraction.
+///
+/// It wraps the bytes of a selected file, and its (platform-dependant) path.
+class XFile extends XFileBase {
+ /// Construct a CrossFile object from its path.
+ ///
+ /// Optionally, this can be initialized with `bytes` and `length`
+ /// so no http requests are performed to retrieve data later.
+ ///
+ /// `name` may be passed from the outside, for those cases where the effective
+ /// `path` of the file doesn't match what the user sees when selecting it
+ /// (like in web)
+ XFile(
+ String path, {
+ String mimeType,
+ String name,
+ int length,
+ Uint8List bytes,
+ DateTime lastModified,
+ @visibleForTesting CrossFileTestOverrides overrides,
+ }) : super(path) {
+ throw UnimplementedError(
+ 'CrossFile is not available in your current platform.');
+ }
+
+ /// Construct a CrossFile object from its data
+ XFile.fromData(
+ Uint8List bytes, {
+ String mimeType,
+ String name,
+ int length,
+ DateTime lastModified,
+ String path,
+ @visibleForTesting CrossFileTestOverrides overrides,
+ }) : super(path) {
+ throw UnimplementedError(
+ 'CrossFile is not available in your current platform.');
+ }
+}
+
+/// Overrides some functions of CrossFile for testing purposes
+@visibleForTesting
+class CrossFileTestOverrides {
+ /// For overriding the creation of the file input element.
+ dynamic Function(String href, String suggestedName) createAnchorElement;
+
+ /// Default constructor for overrides
+ CrossFileTestOverrides({this.createAnchorElement});
+}
diff --git a/packages/cross_file/lib/src/types/io.dart b/packages/cross_file/lib/src/types/io.dart
new file mode 100644
index 0000000..81b8cdd
--- /dev/null
+++ b/packages/cross_file/lib/src/types/io.dart
@@ -0,0 +1,115 @@
+// Copyright 2018 The Chromium 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:convert';
+import 'dart:io';
+import 'dart:typed_data';
+
+import './base.dart';
+
+/// A CrossFile backed by a dart:io File.
+class XFile extends XFileBase {
+ final File _file;
+ final String mimeType;
+ final DateTime _lastModified;
+ int _length;
+
+ final Uint8List _bytes;
+
+ /// Construct a CrossFile object backed by a dart:io File.
+ XFile(
+ String path, {
+ this.mimeType,
+ String name,
+ int length,
+ Uint8List bytes,
+ DateTime lastModified,
+ }) : _file = File(path),
+ _bytes = null,
+ _lastModified = lastModified,
+ super(path);
+
+ /// Construct an CrossFile from its data
+ XFile.fromData(
+ Uint8List bytes, {
+ this.mimeType,
+ String path,
+ String name,
+ int length,
+ DateTime lastModified,
+ }) : _bytes = bytes,
+ _file = File(path ?? ''),
+ _length = length,
+ _lastModified = lastModified,
+ super(path) {
+ if (length == null) {
+ _length = bytes.length;
+ }
+ }
+
+ @override
+ Future<DateTime> lastModified() {
+ if (_lastModified != null) {
+ return Future.value(_lastModified);
+ }
+ return _file.lastModified();
+ }
+
+ @override
+ void saveTo(String path) async {
+ File fileToSave = File(path);
+ await fileToSave.writeAsBytes(_bytes ?? (await readAsBytes()));
+ await fileToSave.create();
+ }
+
+ @override
+ String get path {
+ return _file.path;
+ }
+
+ @override
+ String get name {
+ return _file.path.split(Platform.pathSeparator).last;
+ }
+
+ @override
+ Future<int> length() {
+ if (_length != null) {
+ return Future.value(_length);
+ }
+ return _file.length();
+ }
+
+ @override
+ Future<String> readAsString({Encoding encoding = utf8}) {
+ if (_bytes != null) {
+ return Future.value(String.fromCharCodes(_bytes));
+ }
+ return _file.readAsString(encoding: encoding);
+ }
+
+ @override
+ Future<Uint8List> readAsBytes() {
+ if (_bytes != null) {
+ return Future.value(_bytes);
+ }
+ return _file.readAsBytes();
+ }
+
+ Stream<Uint8List> _getBytes(int start, int end) async* {
+ final bytes = _bytes;
+ yield bytes.sublist(start ?? 0, end ?? bytes.length);
+ }
+
+ @override
+ Stream<Uint8List> openRead([int start, int end]) {
+ if (_bytes != null) {
+ return _getBytes(start, end);
+ } else {
+ return _file
+ .openRead(start ?? 0, end)
+ .map((chunk) => Uint8List.fromList(chunk));
+ }
+ }
+}
diff --git a/packages/cross_file/lib/src/web_helpers/web_helpers.dart b/packages/cross_file/lib/src/web_helpers/web_helpers.dart
new file mode 100644
index 0000000..813f5f9
--- /dev/null
+++ b/packages/cross_file/lib/src/web_helpers/web_helpers.dart
@@ -0,0 +1,38 @@
+// Copyright 2018 The Chromium 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:html';
+
+/// Create anchor element with download attribute
+AnchorElement createAnchorElement(String href, String suggestedName) {
+ final element = AnchorElement(href: href);
+
+ if (suggestedName == null) {
+ element.download = 'download';
+ } else {
+ element.download = suggestedName;
+ }
+
+ return element;
+}
+
+/// Add an element to a container and click it
+void addElementToContainerAndClick(Element container, Element element) {
+ // Add the element and click it
+ // All previous elements will be removed before adding the new one
+ container.children.add(element);
+ element.click();
+}
+
+/// Initializes a DOM container where we can host elements.
+Element ensureInitialized(String id) {
+ var target = querySelector('#${id}');
+ if (target == null) {
+ final Element targetElement = Element.tag('flt-x-file')..id = id;
+
+ querySelector('body').children.add(targetElement);
+ target = targetElement;
+ }
+ return target;
+}
diff --git a/packages/cross_file/lib/src/x_file.dart b/packages/cross_file/lib/src/x_file.dart
new file mode 100644
index 0000000..6136bff
--- /dev/null
+++ b/packages/cross_file/lib/src/x_file.dart
@@ -0,0 +1,7 @@
+// Copyright 2018 The Chromium 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 'types/interface.dart'
+ if (dart.library.html) 'types/html.dart'
+ if (dart.library.io) 'types/io.dart';
diff --git a/packages/cross_file/pubspec.yaml b/packages/cross_file/pubspec.yaml
new file mode 100644
index 0000000..40084d3
--- /dev/null
+++ b/packages/cross_file/pubspec.yaml
@@ -0,0 +1,20 @@
+
+name: cross_file
+description: An abstraction to allow working with files across multiple platforms.
+homepage: https://github.com/flutter/plugins/tree/master/packages/cross_file
+version: 0.1.0
+
+dependencies:
+ flutter:
+ sdk: flutter
+ http: ^0.12.0+1
+ meta: ^1.0.5
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ pedantic: ^1.8.0
+
+environment:
+ sdk: ">=2.1.0 <3.0.0"
+ flutter: ">=1.22.0 <2.0.0"
\ No newline at end of file
diff --git a/packages/cross_file/test/assets/hello.txt b/packages/cross_file/test/assets/hello.txt
new file mode 100644
index 0000000..5dd01c1
--- /dev/null
+++ b/packages/cross_file/test/assets/hello.txt
@@ -0,0 +1 @@
+Hello, world!
\ No newline at end of file
diff --git a/packages/cross_file/test/x_file_html_test.dart b/packages/cross_file/test/x_file_html_test.dart
new file mode 100644
index 0000000..fadba96
--- /dev/null
+++ b/packages/cross_file/test/x_file_html_test.dart
@@ -0,0 +1,108 @@
+// Copyright 2020 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.
+
+@TestOn('chrome') // Uses web-only Flutter SDK
+
+import 'dart:convert';
+import 'dart:html' as html;
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:cross_file/cross_file.dart';
+
+import 'dart:html';
+
+final String expectedStringContents = 'Hello, world!';
+final Uint8List bytes = utf8.encode(expectedStringContents);
+final html.File textFile = html.File([bytes], 'hello.txt');
+final String textFileUrl = html.Url.createObjectUrl(textFile);
+
+void main() {
+ group('Create with an objectUrl', () {
+ final file = XFile(textFileUrl);
+
+ 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));
+ });
+
+ test('Can be read as a stream', () async {
+ expect(await file.openRead().first, equals(bytes));
+ });
+
+ test('Stream can be sliced', () async {
+ expect(await file.openRead(2, 5).first, equals(bytes.sublist(2, 5)));
+ });
+ });
+
+ group('Create from data', () {
+ final file = XFile.fromData(bytes);
+
+ 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));
+ });
+
+ test('Can be read as a stream', () async {
+ expect(await file.openRead().first, equals(bytes));
+ });
+
+ test('Stream can be sliced', () async {
+ expect(await file.openRead(2, 5).first, equals(bytes.sublist(2, 5)));
+ });
+ });
+
+ group('saveTo(..)', () {
+ final String CrossFileDomElementId = '__x_file_dom_element';
+
+ group('CrossFile saveTo(..)', () {
+ test('creates a DOM container', () async {
+ XFile file = XFile.fromData(bytes);
+
+ await file.saveTo('');
+
+ final container = querySelector('#${CrossFileDomElementId}');
+
+ expect(container, isNotNull);
+ });
+
+ test('create anchor element', () async {
+ XFile file = XFile.fromData(bytes, name: textFile.name);
+
+ await file.saveTo('path');
+
+ final container = querySelector('#${CrossFileDomElementId}');
+ final AnchorElement element = container?.children?.firstWhere(
+ (element) => element.tagName == 'A',
+ orElse: () => null);
+
+ expect(element, isNotNull);
+ expect(element.href, file.path);
+ expect(element.download, file.name);
+ });
+
+ test('anchor element is clicked', () async {
+ final mockAnchor = AnchorElement();
+
+ CrossFileTestOverrides overrides = CrossFileTestOverrides(
+ createAnchorElement: (_, __) => mockAnchor,
+ );
+
+ XFile file =
+ XFile.fromData(bytes, name: textFile.name, overrides: overrides);
+
+ bool clicked = false;
+ mockAnchor.onClick.listen((event) => clicked = true);
+
+ await file.saveTo('path');
+
+ expect(clicked, true);
+ });
+ });
+ });
+}
diff --git a/packages/cross_file/test/x_file_io_test.dart b/packages/cross_file/test/x_file_io_test.dart
new file mode 100644
index 0000000..65edea1
--- /dev/null
+++ b/packages/cross_file/test/x_file_io_test.dart
@@ -0,0 +1,99 @@
+// Copyright 2020 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.
+
+@TestOn('vm') // Uses dart:io
+
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:cross_file/cross_file.dart';
+
+// Please note that executing this test with command
+// `flutter test test/x_file_io_test.dart` will set the directory
+// to ./file_selector_platform_interface.
+//
+// This will cause our hello.txt file to be not be found. Please
+// execute this test with `flutter test` or change the path prefix
+// to ./test/assets/
+//
+// https://github.com/flutter/flutter/issues/20907
+
+final pathPrefix = './assets/';
+final path = pathPrefix + 'hello.txt';
+final String expectedStringContents = 'Hello, world!';
+final Uint8List bytes = utf8.encode(expectedStringContents);
+final File textFile = File(path);
+final String textFilePath = textFile.path;
+
+void main() {
+ group('Create with a path', () {
+ final file = XFile(textFilePath);
+
+ 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));
+ });
+
+ test('Can be read as a stream', () async {
+ expect(await file.openRead().first, equals(bytes));
+ });
+
+ test('Stream can be sliced', () async {
+ expect(await file.openRead(2, 5).first, equals(bytes.sublist(2, 5)));
+ });
+
+ test('saveTo(..) creates file', () async {
+ File removeBeforeTest = File(pathPrefix + 'newFilePath.txt');
+ if (removeBeforeTest.existsSync()) {
+ await removeBeforeTest.delete();
+ }
+
+ await file.saveTo(pathPrefix + 'newFilePath.txt');
+ File newFile = File(pathPrefix + 'newFilePath.txt');
+
+ expect(newFile.existsSync(), isTrue);
+ expect(newFile.readAsStringSync(), 'Hello, world!');
+
+ await newFile.delete();
+ });
+ });
+
+ group('Create with data', () {
+ final file = XFile.fromData(bytes);
+
+ 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));
+ });
+
+ test('Can be read as a stream', () async {
+ expect(await file.openRead().first, equals(bytes));
+ });
+
+ test('Stream can be sliced', () async {
+ expect(await file.openRead(2, 5).first, equals(bytes.sublist(2, 5)));
+ });
+
+ test('Function saveTo(..) creates file', () async {
+ File removeBeforeTest = File(pathPrefix + 'newFileData.txt');
+ if (removeBeforeTest.existsSync()) {
+ await removeBeforeTest.delete();
+ }
+
+ await file.saveTo(pathPrefix + 'newFileData.txt');
+ File newFile = File(pathPrefix + 'newFileData.txt');
+
+ expect(newFile.existsSync(), isTrue);
+ expect(newFile.readAsStringSync(), 'Hello, world!');
+
+ await newFile.delete();
+ });
+ });
+}