[cross_file] Improve dart:io saveTo (#404)

Changes the `saveTo` method for the dart:io-based implementation to copy
path-based instances rather than read them into memory and writing them
out again. This makes it *much* more efficient, especially for large
files.

Also adjusts the unit tests to write to a temporary directory rather
than in-tree, since they are using the actual filesystem.

Fixes https://github.com/flutter/flutter/issues/83665
diff --git a/packages/cross_file/CHANGELOG.md b/packages/cross_file/CHANGELOG.md
index a6ace9a..fe50d36 100644
--- a/packages/cross_file/CHANGELOG.md
+++ b/packages/cross_file/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.3.1+4
+
+* The `dart:io` implementation of `saveTo` now does a file copy for path-based
+  `XFile` instances, rather than reading the contents into memory.
+
 ## 0.3.1+3
 
 * Fix example in README
diff --git a/packages/cross_file/lib/src/types/io.dart b/packages/cross_file/lib/src/types/io.dart
index 930b283..2965cc9 100644
--- a/packages/cross_file/lib/src/types/io.dart
+++ b/packages/cross_file/lib/src/types/io.dart
@@ -13,6 +13,10 @@
 /// A CrossFile backed by a dart:io File.
 class XFile extends XFileBase {
   /// Construct a CrossFile object backed by a dart:io File.
+  ///
+  /// [bytes] is ignored; the parameter exists only to match the web version of
+  /// the constructor. To construct a dart:io XFile from bytes, use
+  /// [XFile.fromData].
   XFile(
     String path, {
     this.mimeType,
@@ -62,9 +66,12 @@
 
   @override
   Future<void> saveTo(String path) async {
-    final File fileToSave = File(path);
-    await fileToSave.writeAsBytes(_bytes ?? (await readAsBytes()));
-    await fileToSave.create();
+    if (_bytes == null) {
+      await _file.copy(path);
+    } else {
+      final File fileToSave = File(path);
+      await fileToSave.writeAsBytes(_bytes!);
+    }
   }
 
   @override
diff --git a/packages/cross_file/pubspec.yaml b/packages/cross_file/pubspec.yaml
index b8b62ca..371658c 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+3
+version: 0.3.1+4
 
 environment:
   sdk: ">=2.12.0 <3.0.0"
diff --git a/packages/cross_file/test/x_file_io_test.dart b/packages/cross_file/test/x_file_io_test.dart
index 4affc85..eb42871 100644
--- a/packages/cross_file/test/x_file_io_test.dart
+++ b/packages/cross_file/test/x_file_io_test.dart
@@ -21,36 +21,54 @@
 
 void main() {
   group('Create with a path', () {
-    final XFile file = XFile(textFilePath);
-
     test('Can be read as a string', () async {
+      final XFile file = XFile(textFilePath);
       expect(await file.readAsString(), equals(expectedStringContents));
     });
     test('Can be read as bytes', () async {
+      final XFile file = XFile(textFilePath);
       expect(await file.readAsBytes(), equals(bytes));
     });
 
     test('Can be read as a stream', () async {
+      final XFile file = XFile(textFilePath);
       expect(await file.openRead().first, equals(bytes));
     });
 
     test('Stream can be sliced', () async {
+      final XFile file = XFile(textFilePath);
       expect(await file.openRead(2, 5).first, equals(bytes.sublist(2, 5)));
     });
 
     test('saveTo(..) creates file', () async {
-      final File removeBeforeTest = File(pathPrefix + 'newFilePath.txt');
-      if (removeBeforeTest.existsSync()) {
-        await removeBeforeTest.delete();
+      final XFile file = XFile(textFilePath);
+      final Directory tempDir = Directory.systemTemp.createTempSync();
+      final File targetFile = File('${tempDir.path}/newFilePath.txt');
+      if (targetFile.existsSync()) {
+        await targetFile.delete();
       }
 
-      await file.saveTo(pathPrefix + 'newFilePath.txt');
-      final File newFile = File(pathPrefix + 'newFilePath.txt');
+      await file.saveTo(targetFile.path);
 
-      expect(newFile.existsSync(), isTrue);
-      expect(newFile.readAsStringSync(), 'Hello, world!');
+      expect(targetFile.existsSync(), isTrue);
+      expect(targetFile.readAsStringSync(), 'Hello, world!');
 
-      await newFile.delete();
+      await tempDir.delete(recursive: true);
+    });
+
+    test('saveTo(..) does not load the file into memory', () async {
+      final TestXFile file = TestXFile(textFilePath);
+      final Directory tempDir = Directory.systemTemp.createTempSync();
+      final File targetFile = File('${tempDir.path}/newFilePath.txt');
+      if (targetFile.existsSync()) {
+        await targetFile.delete();
+      }
+
+      await file.saveTo(targetFile.path);
+
+      expect(file.hasBeenRead, isFalse);
+
+      await tempDir.delete(recursive: true);
     });
   });
 
@@ -73,18 +91,31 @@
     });
 
     test('Function saveTo(..) creates file', () async {
-      final File removeBeforeTest = File(pathPrefix + 'newFileData.txt');
-      if (removeBeforeTest.existsSync()) {
-        await removeBeforeTest.delete();
+      final Directory tempDir = Directory.systemTemp.createTempSync();
+      final File targetFile = File('${tempDir.path}/newFilePath.txt');
+      if (targetFile.existsSync()) {
+        await targetFile.delete();
       }
 
-      await file.saveTo(pathPrefix + 'newFileData.txt');
-      final File newFile = File(pathPrefix + 'newFileData.txt');
+      await file.saveTo(targetFile.path);
 
-      expect(newFile.existsSync(), isTrue);
-      expect(newFile.readAsStringSync(), 'Hello, world!');
+      expect(targetFile.existsSync(), isTrue);
+      expect(targetFile.readAsStringSync(), 'Hello, world!');
 
-      await newFile.delete();
+      await tempDir.delete(recursive: true);
     });
   });
 }
+
+/// An XFile subclass that tracks reads, for testing purposes.
+class TestXFile extends XFile {
+  TestXFile(String path) : super(path);
+
+  bool hasBeenRead = false;
+
+  @override
+  Future<Uint8List> readAsBytes() {
+    hasBeenRead = true;
+    return super.readAsBytes();
+  }
+}